Foundry ICS (Interchain Standard) 구현

Junha Yang
코드체인
Published in
35 min readMar 18, 2020
Photo by Kelemen Istvan on Unsplash

CodeChain Foundry는 블록체인 프레임워크로 현재 활발히 개발 중인 프로젝트입니다. Foundry의 핵심 기능 중 하나는 ICS(Interchain standard) 지원인데요, ICS는 Cosmos팀에서 IBC(Inter-Blokchain communication)를 수행하기 위해 제안한 블록체인 표준입니다. (이에 대한 개략적인 소개는 이전 글을 참고하시길 바랍니다.) Foundry가 ICS를 지원한다는 말은 ICS를 따르는 다른 체인들과 바로 통신이 가능하도록 필요한 모든 모듈을 구현해서 제공한다는 뜻입니다. 이를 위해 Foundry팀은 지난 두 달 동안 ICS(Interchain standard)의 핵심 아이디어들을 실험적으로 구현하는 것을 중요 과제 중 하나로서 수행해왔고, 그 과정과 결과를 소개하고자 합니다. 이런 Foundry의 ICS 구현을 이 글에서는 PoC(Proof of Concept) 라고 부르겠습니다.

ICS

ICS는 여러 개의 하위 스펙으로 나눠집니다. 각 스펙은 ICS를 구성하는 요소들로 대부분 호스트와 함께 작동할 모듈이지만 일부는 그렇지 않습니다. (호스트 자체의 요구사항을 명시하는 Host-24, 체인 밖에서 돌아가는 Relayer-18 등) 각 스펙들은 필요한 타입과 함수들을 추상화된 형태로 명시하고 있으며 이들은 다양한 시점에 다양한 형태로 이용됩니다. 여러 스펙이 있지만, 이 중 PoC에 포함된 핵심적인 것들만 살펴보도록 하겠습니다.

Host-24

Host는 ICS 모듈들에게 추상화된 인터페이스로서 제공되는 블록체인 풀 노드입니다. ICS를 구동하기 위해 풀 노드가 ‘호스트’로서 제공해야 할 것 중 몇 가지 중요한 것은 다음과 같습니다.

  • 모듈 시스템: 모듈은 한 체인 안에서 기능별로 구분되는 단위입니다. 각각은 결정론적으로 동작하여 컨센서스에 도달할 상태변화를 만들어냅니다. ICS는 그런 모듈로서 체인에 구현되기를 요구합니다. 또한 ICS 모듈을 사용하는 어플리케이션 모듈도 존재할 것입니다.

Foundry의 모듈 시스템 Mold가 이를 지원합니다. Mold는 현재 개발 중이며 향후 자세히 소개될 예정입니다.

  • Key-value store: 호스트는 키를 가지고 값을 얻어낼 수 있는 독립적인 데이터 구조를 제공해야 합니다. 대부분의 경우 전체 스테이트의 일부를 prefixed 키공간으로 할당함으로써 구현하는 것이 직관적인 제공 방법이 될 것입니다. 또한 ICS 모듈이 저장하는 데이터들 중 일부는 증명이 가능해야 합니다.

Foundry에서는 마찬가지로 원래 있던 State DB를 사용하되 prefix를 붙여 필요한 연산(get(), delete(), set(), make_proof() 등)의 인터페이스만 제공하는 KVStore을 만들고, 이를 'Context'에 포함해 ICS 모듈들에게 제공했습니다. 또한 증명 가능한 영역과 불가능한 영역을 구분하지않고 전부 증명 가능하게 만들었습니다.

  • 컨센서스 정보 제공: ICS는 호스트 체인에 대해 모르는 상태로 동작하게끔 디자인되어 있지만, 소수의 특수한 정보는 제공되어야 합니다. 체인의 높이가 그 예시입니다. (타임아웃을 계산할 때 사용됩니다)

Foundry에서는 단순히 ICS에 제공되는 컨텍스트에 KVstore와 함께 Height를 포함했습니다.

  • 예외처리: ICS 모듈들은 트랜잭션을 수행하는 도중에 언제든지 트랜잭션을 실패시킬 수 있어야 하며 그 경우 호스트는 모든 스테이트 변화를 수행 전으로 되돌려야 합니다.

Foundry에서는 ICS와 상관없이 원래 이러한 예외처리가 가능한 스테이트 구조를 가지고 있었기에 별문제가 되지 않았습니다.

  • 이벤트 시스템: ICS 모듈들은 트랜잭션의 수행 결과로서 임의의 데이터인 ‘이벤트’를 기록할 수 있는 시스템을 제공해야 합니다. 스테이트와 다르게 영원히 보존되지 않지만 조회될 수 있으며 헤더에 암호학적인 루트가 남겨져야 합니다. Relayer가 이를 적극적으로 활용합니다.

Foundry의 모듈 시스템 Mold는 향후 이벤트 시스템을 지원할 예정이지만 당장은 없었기에 그냥 스테이트의 일부를 사용하는 임시방편을 사용했습니다.

Client-02

클라이언트라는 이름이 다소 모호하지만, ICS에서 클라이언트는 ‘상대 체인에 대한 라이트 클라이언트’를 말합니다. 라이트 클라이언트는 스테이트를 가지고 있지 않은 상태로도 암호학적으로 신뢰할 수 있는 방법으로 헤더들의 체인을 업데이트해나갈 수 있는 기술입니다. (이에 대한 자세한 설명은 이전 글을 참조해주세요.) ICS에서 체인간 소통을 신뢰할 수 있게 하는것이 이 라이트 클라이언트의 역할이라고 볼 수 있겠습니다.

  • 왜 라이트 클라이언트가 필요한가: ICS에서 라이트 클라이언트가 필요한 이유는 간단합니다. ICS에서는 수많은 종류의 체인 간 소통이 존재하지만 기본적으로 한쪽이 먼저 상대 체인에 의사를 밝히면 그걸 받은 상대 체인이 받았다는 의사를 밝히는 식입니다. 이 과정에서 ‘난 보냈다’ 혹은 ‘난 받았다’ 등의 기록은 당연히 블록체인에 스테이트의 일부로써 영구적으로 남아야할 것이고, 이걸 확인해야만 비로소 상대 체인의 의사를 확인할 수 있게 됩니다. 스테이트에 뭔가가 포함됐는지(혹은 안됐는지)는 헤더에 적혀있는 암호학적 루트와 암호학적 증명을 이용해 가볍게 검증할 수 있고, (이에 대한 알고리즘은 Commitment-23이 제공합니다) 헤더에 적혀 있는 암호학적 루트를 이용하기 위해서 해당 블록의 헤더는 이미 검증이 되어 있어야 합니다. 헤더만을 검증한다는 관점에서 라이트 클라이언트가 정확히 필요한 일을 수행한다고 볼 수 있습니다.
  • 헤더 체인의 업데이트: 검증된 헤더 체인을 업데이트해나가기 위해 Relayer는 각 체인에게 상대 체인의 ‘Header’를 계속 제공해야 합니다. 여기서 등장하는 Header는 블록의 헤더랑은 다른데 헤더 체인에 새로운 헤더를 추가하고 검증하기 위해 필요한 모든 정보를 의미합니다. 새로운 헤더 자체의 일부는 당연히 포함 될 것이고, 헤더에 대한 서명과 그 서명이 유효한지 확인하기 위한 위원회집합 등이 포함될 것입니다.
  • 클라이언트의 상태: 여기서 한 가지 놓치지 말아야 할 점은 어떤 체인이 유지하고 있는 헤더 체인은 ‘상대 체인’에 대한 것이라는 사실입니다. 내가 검증하고 싶은 것은 상대 체인에 뭔가가 기록되거나 기록되어있지 않다는 증명이기 때문입니다. 이를 위해 ‘내 스테이트’에 상대 체인에 대한 추상화된 스테이트, 정확히는 상대 체인을 업데이트하고 있는 상대 체인의 라이트 클라이언트의 스테이트인 ClientState를 저장합니다. 이 정보는 다음 Header가 들어왔을 때 새로운 ClientState와 해당 블록에 대한 ConsensusState를 연역하기에 충분한 정보여야 합니다. 라이트 클라이언트라는 개념 자체가 체인들의 컨센서스 알고리즘에 의존적이기 때문에 들어가는 내용은 천차만별일 것입니다.
  • 컨센서스의 상태: 클라이언트의 상태는 다음 Header의 진위를 검증하기 위한 정보이지만 컨센서스의 상태(다소 헷갈리는 이름입니다)인 ConsensusState는 임의 높이의 블록에 대한 암호학적인 증명을 검증할 수 있는 정보와 누군가 체인이 실패했음을(같은 높이에 2개의 블록이 커밋되는 등) 보고할 때에 이를 검증할 수 있는 당시 상황을 기록하는 것이 목적입니다. 전자는 Commitment-23에서 사용하는 (검증된) 데이터고, 후자의 경우 거버넌스로 해결될 때까지 해당 체인에 대한 IBC를 영원히 잠가 버리기 위한 장치입니다.

PoC에서는 후자에 해당하는 ‘Misbehavior’ 감지 로직은 구현하지 않았습니다.

  • 어디에 얼마나 저장하는가: 위에서 소개된 두 스테이트는 목적과 관리방식이 다소 다릅니다. 눈치채셨을 수도 있겠지만 ClientState는 블록이 업데이트 될때마다 바뀌는, 최신으로 유지되는 1개의 값입니다. 반면 ConsensusState는 매 블록마다 존재하는 값으로 블록이 자랄수록 누적적으로 쌓이게 됩니다. 여기서 혼동하지 말아야 할 점은 위에서 언급한 ‘블록’은 상대 체인의 블록이라는 것입니다.
  • 예를 들어 체인A와 B가 있다고 생각해봅시다. 체인A가 가지고 있는 클라이언트는 B에 대한 것입니다. 체인 A의 높이 100에 B의 높이 200에 대한 ClientState가 기록되어있다고 생각해봅시다. 그럼 A의 100에는 1부터 200까지 모든 블록에 대한 ConsensusState가 한 번에 기록되어 있어야 합니다. 만약 B가 업데이트 속도가 빨라서 A가 101이 될 동안 B가 222가 되어있었고, 22개에 해당하는 UpdateClient 트랜잭션을 누군가가 다 101번째에 다 포함했다면 A의 101번째 스테이트에는 새로 업데이트된 222번째 블록에 대한 ClientState가 하나있고, 새로 추가된 22개의 ConsensusState도 있을 것입니다. (원래 있던 200개는 물론이고요) 반면 101이 될 동안 B의 블록이 새로 추가되지 않았거나, 그렇다고 해도 UpdateClient 트랜잭션을 아무도 보내지 않았으면 A의 스테이트는 101번째에서도 똑같을 것입니다.

현재 Foundry는 헤더의 서명 스킴 등을 실험적으로 많이 바꾸고 있고, 라이트 클라이언트 최적화를 위한 블록 해쉬 방식의 변경 등이 생긴다면 라이트 클라이언트 알고리즘도 같이 바뀔 것입니다. 다른 스펙과 다르게 Client-02는 특정 체인에 매우 특정적이면서 중요한 내용이고, 또한 Foundry가 아닌 다른 체인의 개발자들이 쉽게 이해할 필요가 있습니다. 이에 따라 Foundry의 Client-02 구현체를 설명하는 문서는 따로 공개할 예정입니다.

한 가지 혼란스러울 수 있는 점은 Client-02는 상대 체인의 라이트 클라이언트인데 왜 Foudry 자신의 라이트 클라이언트 알고리즘을 구현했냐는 것입니다. 답변은 간단합니다. PoC에서는 Foundry-Foundry 통신을 실험했기 때문에 상대 체인이 Foundry입니다!

Commitment-23

Commitment-23은 스테이트에 어떤 정보가 존재하는지, 혹은 존재하지 않는지에 대한 증명을 만들고 검증하는 일을 담당하는 스펙입니다. 앞서 설명했듯이 암호학적인 루트와 암호학적인 증명을 이용합니다. ICS의 특성상 추상적으로 설명되어 있으나 현실적으로 대부분의 경우에는 머클증명이 그 구현이 될 것입니다. 한 가지 특이한 점은 부재의 증명도 지원해야 한다는 것입니다. 뒤에서 설명할 Channel-and-Packet-04에서는 패킷의 타임아웃에 대한 내용이 있는데요, 타임아웃을 처리하기 위해서는 상대 체인에 수신의사가 포함되지 않았다는 부재의 증명을 들고 와야 합니다.

Foundry가 독자적으로 사용하는 Merkle Trie 라이브러리에서 머클증명에 대한 기능을 추가하고 이를 그대로 쓰고 있습니다. 아직 증명의 사이즈에 대한 최적화를 더 할 필요가 있습니다.

한 가지 혼동할 수 있는 점은 Commitment-23의 절반은 내 체인에 대한 것이고, 나머지 절반은 상대 체인에 대한 것이라는 점입니다. 예를 들어 증명을 만드는 함수는 당연히 내 체인에 있는 내 스테이트에 대한 것입니다. 반면 증명을 검증하는 함수는 상대 체인의 스테이트에 대한 것입니다. 특히 후자의 경우 상대 체인이 모듈로써 이용할 내 체인에 대한 모듈(Client-02)에 포함되는 루틴이라는 점은 매우 헷갈릴 수 있습니다.

Foundry에서는 이를 구분하지 않고 Foundry의 Merkle Trie에 대한 알고리즘으로 묶어서 구현한 후에, 이 중 특별히 검증과 관련된 함수와 타입들을 Client-02에 재선언하여 (마치 Client-02의 구현체에 포함되어있는 것처럼) 나눴습니다. 뒤에 등장할 개발책임에 대한 이야기에서 언급하겠지만, Foundry팀은 검증에 해당하는 Commitment-23의 절반은 Client-02의 구현체에 포함되는 게 자연스럽다고 판단했습니다.

Connection-03

Connection은 체인과 체인 사이의 소통을 위한 최하위 레이어 연결을 나타냅니다. 체인 사이에 처음 한 번만 성립하고 쭉 쓰게 되는데요, 서로에 대한 라이트 클라이언트를 지정하여 ‘검증 가능한 통신’을 성립하는 역할을 합니다. 즉 Connection 레이어 위의 소통은 최소한 검증(상대 체인의 스테이트에 포함/불포함을 증명)은 가능한 상태가 됩니다.

이 과정에서 서로의 의사를 확인하기 위해 핸드쉐이크 (TCP의 핸드쉐이크와 유사합니다)를 통해 연결을 확정합니다. 핸드쉐이크를 진행하는 상대방의 상태변화는 지금 성립하려는 Connection에서 지정된 라이트 클라이언트를 통해 검증합니다. 예를 들어 conn_open_init()은 자신의 스테이트 어딘가에 INIT상태의 ConnectionEnd를 저장하고, 그 이후에 상대 체인에서 호출될 차례인 conn_open_try()는 방금 상대방이 INIT으로 저장한 ConnectionEnd의 값과 그 머클증명을 Relayer로부터 받는 식입니다. 이후의 프로토콜인 ChannelPacketConnection 위에서 이뤄지고 Connection에서 지정된 라이트 클라이언트로 검증됩니다.

Connection 성립 과정에서 내 체인에 대한 ConsensusState를 호스트로 얻어와 검증하는 루틴이 있습니다. Client-02에서 언급되는 ConsensusState는 상대 체인에 대한 것인데, 특별히 내 체인의 ConsensusState를 만들어주는 기능이 Host-24컨센서스 정보 제공 요구사항 중 하나입니다. PoC에서는 그 검증 루틴의 필요성을 정확히 이해하지 못했기에 일단 생략했습니다.

Channel-and-Packet-04

Packet은 실제로 체인이 어플리케이션 레벨에서 주고받는 정보이고, Channel은 그 패킷들의 순서와 유일송신성(네트워크 상태와 상관없이 한 번만 보내짐)을 보장하기 위한 레이어입니다. ChannelConnection과 마찬가지로 핸드쉐이크를 통해 성립되고, 그 과정은 Connection과 거의 비슷합니다. 다만 Channel을 처음 만들 때 ORDEREDUNORDERED를 선택할 수 있는데, 전자의 경우 먼저 보내진 Packet은 항상 먼저 처리됩니다. 즉 처리되는 순서가 완벽히 보장됩니다. 후자의 경우 여러개의 Packet을 보냈을 때 받는 쪽에서 순서대로 처리되지 않아도 되나 유일 송신성은 여전히 보장됩니다.

PoC에서는 상대적으로 간단한 Ordered만 구현하였습니다.

Packet은 어플리케이션 모듈이 담고 싶은 어떤 내용이든지 담을 수 있는 data 필드가 있습니다. ICS는 그 내용에 대해 전혀 상관하지 않으며 단순히 신뢰할 수 있는 방법으로 다른 체인의 어플리케이션 모듈에 전해줄 뿐입니다. Packet의 송신은 이미 성립된 Channel 위에서 이뤄지며, 보낸 이후에는 RecvAck으로 전달된 것을 안전하게 확인할 수 있습니다. (전반적으로 TCP의 Reliability 보장과 비슷합니다) 만약 일정 블록동안 상대 체인이 반응이 없을 경우 타임아웃이 되며, 이 경우에 타임아웃이 된 높이의 블록에 Recv가 스테이트에 포함되지 않았다는 부재의 증명을 송신 체인에 보내 타임아웃이 됐다는 걸 증명하여 해당 패킷을 포기하게 만들 수 있습니다. 이런 과정에서 패킷 자체를 스테이트에 남기지는 않습니다. 다만 패킷의 데이터와 타임아웃에 대한 해쉬만 스테이트에 남기고, 실제 패킷 원본은 '이벤트 로그'를 통해 남깁니다. 뒤에서 설명할 Relayer-18이 이 이벤트 로그를 조회하여 패킷을 퍼갑니다.

PoC에서는 타임아웃과 관련된 구현을 포함하지 않았습니다. 부재의 증명은 타임아웃에서만 쓰이나, Commitment-23에는 구현하였습니다.

Handler-25

Handler는 어플리케이션 모듈들에게 제공되어야 할 인터페이스의 집합입니다. 다시 한번 IBC의 역할을 되돌아보면, 다음과 같습니다.

“IBC is an inter-module communication protocol, designed to facilitate reliable, authenticated message passing between modules on separate blockchains”

이런 IBC의 성질을 만족하기 위해 만들어진 것들이 앞서 나온 Client, Commitment, Connection, Channel, 그리고 Packet과 같은 하위 ICS 스펙들이고 최종적으로 IBC를 하려는 어플리케이션 모듈이 마주하게 되는 인터페이스는 Handler입니다. 앞서 나온 하위 스펙들의 구성 함수들은 대부분 Handler의 일부입니다. 예를 들어 Channel-and-Packet-04의 요구사항인 send_packet()이나 recv_packet()등은 이름만 봐도 어플리케이션 모듈들이 하고 싶어 하는 일들입니다. 물론 그 외에 chan_open_init()이나 create_client()와 같이 아주 가끔 함직한 인터페이스들도 있습니다.

앞서 언급했듯이 Foundry에 아직 모듈 시스템이 도입되지 않았기에 특별히 모듈과 소통하는 Handler는 없으나 트랜잭션을 실행하는 transaction_handler가 마주하는 함수들의 집합을 Handler의 대략적인 형태라고 이해할 수 있습니다. 아래 스펙들의 관계 문단에서 좀 더 자세히 설명하였습니다.

Relayer-18

Relayer는 매우 특별합니다. 주요 스펙 중 유일하게 Off-chain을 담당하기 때문입니다. 또한 체인 간에 정보가 물리적으로 전달되는 것도 Relayer을 통해서 이뤄집니다. Client-02Commitment-23safety를 보장하기 위한 것이라면, Relayer-18는 IBC의 liveness보장을 위해 필요합니다.

IBC가 제대로 동작하기 위해서는 liveness가 유지되는 Relayer가 최소한 하나 이상 필요합니다. Relayer는 각 체인 밖에서 별도의 프로그램으로 동작합니다. Relayer는 연결되어 있는 체인들의 정보를 읽고, 그 상황에서 필요한 트랜잭션들을 만들어서 다른 체인에게 보내줍니다. 일단 제대로 동작하는 Relayer가 하나만 있다면 동시에 여러 Relayer가 동작해도 상관없고, 심지어 하나를 제외한 다른 모든 Relayer가 잘못된 정보를 악의적으로 전달해도 괜찮습니다. 모든 정보에 대한 검증은 Client-02를 통해서 할 수 있기 때문입니다. 대신 필요한 모든 정보가 트랜잭션의 형태로 블록 안에 포함 되어야 합니다. 이 과정에서 Relayer는 각 체인의 컨센서스로부터 완전히 별도로 동작합니다.

Foundry에서는 Relayer를 위한 RPC를 추가했으며, Relayer는 각 체인에 계정을 가짐으로써 직접 트랜잭션을 제출합니다.

절차는 다음 대략과 같이 이해할 수 있습니다.

  1. 체인 A의 풀 노드로 부터 필요한 정보와 그 증명을 얻어냅니다.
  2. 그 정보를 체인 B가 받아들일 수 있는 트랜잭션으로 만듭니다.
  3. 트랜잭션에 Relayer 계정으로 서명하고 수수료를 포함하여 B의 풀 노드에게 전해줍니다.
  4. B는 해당 트랜잭션을 전파하거나 블록에 포함하여 제안합니다.

Relayer의 구현은 체인의 종류에 따라 상이합니다. 호스트와 Relayer가 소통하는 방식은 자유롭습니다. Relayer는 물리적인 정보의 전달을 보장하기 위한 스펙이고, 이를 위해 호스트와 소통하는 방식은 체인마다 다를 것입니다. 당장 IBC를 진행시키기 위해 필요한 Handler 함수들을(client_update(), conn_open_try() 등)을 호출하려면 어떤 Datagram(ICS 용어로, 대략 블록체인의 트랜잭션에 대응된다고 이해하실 수 있습니다.)을 만들어야 하는지, 그 포맷은 뭔지부터 알아야 합니다. 그래서 Relayer는 이와 같은 각 체인의 IBC 구현 디테일에 대해 잘 알고 있어야 합니다.

실제로 유저의 의사에 의해서(Relayer가 아닌 일반 계정들이 제출한 트랜잭션)만 시작되는 Handler 호출도 있습니다. 예를 들어 패킷을 보내는 행위 등입니다. 그러나 통신을 위해 필요한 다른 Handler 호출은 Relayer가 자동으로 그에 맞는 Datagram을 만들어 제출함으로써 이뤄집니다. 라이트 클라이언트를 업데이트하는 Datagram(UpdateClient), 두 체인이 Connection을 맺기 위해 INIT 상태변화를 누군가 시작한 후 Connection을 성립하기까지 필요한 Datagram들(ConnOpenTry, ConnOpenAck, ConnOpenConfirm), 두 체인이 Channel을 맺기 위해 INIT 상태변화를 누군가 시작한 후 Channel을 성립하기까지 필요한 Datagram들(ChanOpenTry, ChanOpenAck, ChanOpenConfirm)과 누군가 Packet을 전달했을 때 그 Packet의 안전한 도달을 보장하기위한 Datagram들(PacketRecv, PacketAcknowledgement)을 만들어서 각 체인에 보냅니다. 이름에서 부터 알 수 있듯이 각 Datagram은 하나의 Handler 함수 호출에 대응됩니다.

스펙들의 관계

여기까지 따라오셨다면 각 스펙들이 왜 필요하고 무슨 일을 하는지는 이해하셨겠지만, 각 스펙들이 정확하게 어떤 위치에 있고, 서로 어떤 관계를 가지며 어떤 시점에 누구에 의해 어떻게 호출되는지 매우 혼란스러울 것입니다. 간단하게 정리하자면 다음과 같습니다.

  • 대부분의 함수들은 트랜잭션을 수행하는 도중에 같이 실행됩니다. 정확히는, Handler 인터페이스에 포함된 함수들은 어플리케이션 모듈이 실행할텐데, 어플리케이션 모듈은 트랜잭션의 수행결과로서 동작하기 때문이라고 볼 수 있습니다. 그렇지 않은 함수들에 대해서는 아래 스펙의 분류 문단에서 설명하겠습니다.

PoC에서는 어플리케이션 모듈이 아직 없기 때문에 Handler를 구성하는 각 함수들에 직접적으로 대응하는 트랜잭션들을 추가했습니다. 예를들어 UpdateClient라는 트랜잭션이 있고 이를 Relayer 계정이 서명하여 마이너에게 주는 식입니다. 이렇게 ICS 스펙상의 함수들과 일대일 대응되는 ICS 전용 트랜잭션들을 처리하는 함수로 transaction_handler를 만들었으며, 사실상 이 함수가 마주하는 인터페이스가 미래의 Handler라고 볼 수 있습니다. 물론 어플리케이션 모듈이 추가된 이후에는 ICS 함수들이 트랜잭션과 일대일 대응될 필요는 없고, 모듈이 다른 트랜잭션을 수행하는 도중의 사이드 이펙트로 호출 될 수도 있습니다.

  • 각 스펙들은 서로에 대해 알고 있을 수도, 모를 수도 있습니다. 예컨대 Client-02Channel-and-Packet-04에 대해 모르지만 Channel-04Client-02의 기능을 사용합니다. 예를들어 verify_channel_end()Client-02의 일부이자 Channel-and-Packet-04가 사용하는 함수입니다. ICS 공식 깃허브 저장소에 디펜던시를 그래프로 표현한 도식이 있으니 참고하시길 바랍니다.
  • 스펙들은 (Relayer 제외) 그 자체로 모듈이어도 상관없습니다. 만약 모듈 시스템이 있는 체인이라면 다른 어플리케이션 모듈과 함께 모듈로서 제공되는 것을 생각해볼 수 있습니다.

Foundry또한 향후에는 모듈로서 ICS스펙의 구현체를 제공할 예정이지만, 당장은 호스트에 임베딩되어있습니다.

스펙의 분류

ICS 스펙들의 관계를 이해했다면, 스펙들을 여러기준으로 구분해 볼 수 있습니다.

첫 번째는 누가 사용하느냐에 따른 분류입니다.

  • 어플리케이션 모듈: 앞서 설명한 Handler-25는 같은 블록체인 내의 다른 어플리케이션 모듈들이 사용하는 인터페이스이며, 대부분의 다른 스펙들을 노출해야합니다. Client-02client_update()Channel-and-Packet-04chan_open_init(), send_packet()등 많은 함수들, 그리고 그에 필요한 많은 데이터 타입들 (ChannelEnd, ConsensusState, _Packet _등)이 포함될 것입니다.
  • Handler 내부: 어떤 함수들은 (Handler의 일부인) 다른 스펙이 내부적으로 사용합니다. 예를들어 Client-02의 일부인 ‘verifychannelend()’는 Channel을 성립하는 과정에서 상대 체인이 의사를 반영했는지 검증하기 위해 Channel의 핸드쉐이크 과정에서 종종 불립니다. 하지만 어플리케이션 모듈에게는 제공될 필요가 없습니다.
  • Relayer: 체인 외부에서 동작하는 Relayer가 전달할 정보를 찾기위해 불립니다. 최종 인터페이스는 RPC등이 되겠지만 그 RPC를 수행하는 과정에서 대부분의 query류 함수나 Commitment-23create_membership_proof()등이 호출 될 수 있습니다. 또한 client_update()를 유발하는 Datagram UpdateClient을 만들기 위해 필요한 Header를 특정 높이에 대해 생성해주는 함수도 필요합니다. 이는 스펙의 일부는 아니지만 이를 각 체인이 필수적으로 Relayer에게 제공해줘야하는 정보입니다.

Foundry에서는 ICS를 구현하면서 크게 3종류의 RPC를 추가했습니다. 상대방이 검증하고 싶어하는 ICS 데이터들을 쿼리하고 머클증명을 만들어 주는 ‘쿼리’, 그리고 상대방이 갖고 있는 내 라이트 클라이언트를 업데이트하는 Header를 만들어주는 ‘헤더생성’, 그리고 모듈이 보내고싶어하는 패킷이 기록된 이벤트 로그에 접근하는 ‘이벤트조회’ 입니다. 이 세 종류의 정보만 Relayer가 오고가며 잘 전달해주면 IBC는 작동합니다.

상대 체인에 대한 인지가 있느냐에 따라도 분류할 수 있습니다.

  • 있다: Client-02는 상대 체인의 라이트 클라이언트입니다. 상대 체인의 컨센서스 알고리즘, 블록 헤더에 쓰여있는 정보, 서명 방식, 암호학적 스킴들을 전부 알고 있어야합니다. 이를 검증할 때에는 스테이트에 대한 증명을 수행하기 위해 상대 체인의 스테이트 자료구조 또한 미리 알고 있어야합니다. 이는 Client-02의 구현체가 포함하고 있는 Commitment-23의 절반 (증명의 검증; 나머지 절반인 증명의 생성은 내 체인에 관한 것입니다)의 몫이 될 것입니다. 이 때 여러차례 필요한, 상대 체인이 사용하는 데이터 인/디코딩의 책임도 가지는 것이 자연스럽습니다. 자세한 내용은 데이터 인코딩 문단을 확인하실 수 있습니다.
  • 없다: 나머지 스펙들은 다른 체인과 소통하기 위한 루틴들이지만 그 체인이 누구인지랑은 별개로 작동할 수 있습니다. 예를들어 새로운 체인과 IBC를 하려면 Connection도 새로 성립해야 하지만 그 과정에서 상대 체인의 종류와 이름, 그에 따른 라이트 클라이언트 지정만 다르게 하면 되는 것이고 그 외의 핸드쉐이크 같은 루틴들은 상대 체인의 내부구조와 전혀 무관합니다.

ICS를 구현하면서 한 가지 혼동이 됐던 점은 각 스펙들의 개발 책임이 어떻게 되냐는 것이었습니다. ICS는 엄밀히 말하면 모듈-모듈의 소통을 위한 프로토콜이기 때문에 블록체인 코어와 어플리케이션 모듈이 어떤식으로 개발되어 ICS의 각 파트가 누구에 의해 어떤 타이밍에 추가되는지는 다소 불명확했었습니다.

이는 ICS를 구현하려는 체인마다 그 양상이 다소 다를 수 있으나, Foundry에서는 다음과 같이 정리했습니다.

  • 코어 엔진과 함께: 블록체인, 혹은 스테이트 머신 (ICS에서는 추상화를 위해 종종 이렇게 부릅니다)이 핵심적으로 제공해야하는 기능이 Host-24입니다. 이는 블록체인 코어 엔진과 함께 구현되어야 할 기능이고 사실 ICS가 아니더라도 다른 목적으로 존재할 확률이 높은 기능들입니다. 당연히 Foundry에도 다 포함이 될 것입니다.
  • 기본으로 제공되는 모듈: Foundry의 핵심 기능 중 하나인 ‘ICS 지원’은 이것을 일컫는 말입니다. 스테이킹 모듈과 같이 블록체인 어플리케이션을 만들 때 대부분의 경우 필요하고, 또 그 형태를 거의 바꾸고 싶어하지 않을 기본제공 모듈들 중 하나로 ICS Handler-25 구현체가 제공 될 수 있습니다. 아래의 데이터 인코딩 문단에서 설명하겠지만 Foundry에서는 Handler는 IBC를 새로 맺을 때 마다 바뀌는게 아니라 일반적으로 재사용 할 것을 염두에 두고 구현되었습니다. 하지만 이 중 Client-02Commitment-23의 절반(검증)은 아래에 해당합니다.
  • 체인 사용자가 추가하는 모듈: 새로운 종류의 체인과 IBC를 맺을 때에는 그에 맞는 새로운 Client-02Commitment-23의 절반이 추가되어야 합니다. 다행히도 Foundry를 이용하여 커스터마이즈된 블록체인을 만드는 개발자들은 Foundry의 모듈 시스템인 Mold를 이용하여 자유롭게 모듈을 추가할 수 있습니다. 다른 Handler 구성요소는 공용으로 제공되므로 상대 체인에 대한 ClientCommitment만 만들어 추가하면 됩니다.
  • 제3자: Relayer는 체인에 대해 존재하는 것이 아니라 체인-체인에 대해 존재합니다. 예를 들어 Foundry와 Cosmos가 IBC를 하고 싶다면 각자가 ICS Handler를 구현하고, 서로에 대한 Client도 구현해야합니다. 하지만 Relayer는 둘 간에 1개만 존재하면 되는데요, 이걸 누가 구현할지는 애매한 문제입니다. Relayer의 요청에 응하는 호스트의 표준이 구체적으로 생긴다면 체인이 IBC를 맺을 때 마다 새로 구현할 필요가 없겠지만 지금 당장은 Relayer는 체인-체인 페어에 상당히 구체적인 구현이 필요합니다. 아마 두 체인의 개발자 중 더 절실한(?) 쪽이 하게 될 것 같습니다.

라이트 클라이언트 구현

Client-02는 ICS에서 체인간 통신의 신뢰성을 해결하기 위해 도입한 아이디어로써 매우 중요하지만, 그 구현이 어렵습니다. 왜냐하면 다른 스펙과 달리 체인마다 구현이 완전히 달라지기 때문입니다. 또한 라이트 클라이언트의 구현 주체는 본 체인과 달리 상대 체인의 개발자이기 때문인 점도 있습니다.

Foundry의 ICS PoC에서도 라이트 클라이언트 구현에 많은 노력을 했으며, 잘 동작하는 라이트 클라이언트를 구현했습니다. 위에서 언급했듯 Foundry의 Client-02 구현체와 그 알고리즘은 다른 문서에서 자세히 설명할 가치가 있는 내용이기에 여기서는 Client-02를 개발할 때에 있었던 고충을 몇가지 소개합니다.

  • ClientStateConsensusState의 구분: 둘은 상당히 헷갈리는 개념입니다. 특히 ICS 스펙에서는 굉장히 추상적으로 설명하고 있기에 더더욱 그렇습니다. 위에서 설명했듯이 ClientState는 최신 1개만 존재하고, ConsensusState는 이전의 모든 블록에 대해 다 Map의 형태로 접근할 수 있는 정보라는 것을 생각해보면 각각에 뭐가 들어갈지 조금 분명해집니다. 또한 ClientState와 다르게 ConsensusState는 증명가능해야합니다. (Connection을 성립할 때 사용됩니다.)
  • 스탠드얼론 라이트 클라이언트와의 구분: ICS와 별개로 사용자들이 가볍게 지불을 검증하고 헤더체인을 검증해나가는 라이트클라이언트는 원래 있는 개념입니다. 이를 ICS의 라이트 클라이언트와 구분하기 위해 ‘스탠드얼론’이라고 Foundry팀에서 종종 불러왔습니다. 둘 다 핵심 알고리즘은 같을 것입니다. 컨센서스에 따라 다르겠지만 대략 공통적으로 이전 블록 넘버, 위원회집합, 서명으로부터 안전하게 다음 헤더의 유효성을 검사할 것입니다. 다만 스탠드얼론과 다르게 ICS의 라이트 클라이언트는 트랜잭션을 검증할 필요는 없기에 스테이트 루트에 관련된 정보만 들고 있고 이를 ConsensusState에 남깁니다. 그 외에도 ICS 라이트 클라이언트는 스탠드얼론 라이트 클라이언트에 비해 부분적인 정보만 필요하기에 최적화가 가능할 수도 있습니다. (아래 문단 참고)
  • 블록체인 코어의 수정: Foundry는 ICS이전에 라이트 클라이언트에 대한 심도있는 고려를 하진 않았었습니다. Header에 들어갈 정보중에 위원회집합이 있는데, 이 정보는 스테이트 트라이안에 들어있기 때문에 검증가능한 형태로 넘겨주려면 길다란 머클 증명을 통째로 넘겨야합니다. 당연히 그 머클증명은 UpdateClient를 유발하는 트랜잭션 안에도 어떤식으로든지 들어갈 것이고 이는 트랜잭션을 p2p 네트워크에서 주고 받는데 꽤나 부담이 됩니다. 따라서 Foundry에서는 사전작업 단계에서 블록헤더에 다음 위원회집합의 해쉬를 직접 박아넣는 컨센서스 수정을 거쳤습니다. 또한 추가적으로 블록의 해쉬방식을 height-2 머클트리로 바꿔서 ICS 라이트 클라이언트에 필요한 헤더의 필드들과 아닌 필드들을 나눠서 후자는 해쉬만 받는식으로 용량을 줄이는 최적화도 고려중입니다.
  • Verify 함수들: Client-02에는 많은 verify_…() 함수들이 있습니다. 증명이 존재하는 블록 높이, 검증할 값과 그 값이 저장되어있는 Path를 연역할 수 있는 몇가지 정보를 받아 머클증명을 확인한다는 공통점이 있습니다. 종류가 많아서 복잡해보이지만 대부분 반복적인 일을 함으로 제네릭 프로그래밍으로 손쉽게 리팩토링 할 수 있습니다. 다만 검증대상의 데이터타입이 그때그때 다르기 때문에 인코딩 루틴도 달라져야합니다. Foundry의 경우에느 Rust의 타입추론 시스템 덕분에 그 마저도 제네릭하게 잘 해결됐습니다.
  • 모듈 인터페이스: 체인의 종류에 따라 Client-02의 구현체가 여러개가 되어야한다고 설명했지만, 사실 더 추상화를 하면 Client-02의 구현체는 단 한개고, 그 구현체는 Foundry 모듈시스템상에 같이 로드되어있을 것으로 예상되는 순수 라이트 클라이언트 모듈(ICS와 무관하지만 독자적인 표준 인터페이스가 있는)의 래퍼가 되는 형태를 생각해볼 수 있습니다. 사실 Client 관련 코드에서 항상 클라이언트 식별자를 받는 것으로 보아 ICS에서 원하는 형태가 이런 식일 확률이 높습니다. 이 경우에는 Client-02Commitment-23을 포함한 Handler-25전체가 모든 IBC 연결에 대해 동일하게 사용가능합니다. Foundry의 PoC구현에서는 어차피 상대 체인이 Foundry밖에 없으므로 이런 모듈 분리를 하진 않았습니다.

데이터 인코딩

한 가지 조심해야할 것은 데이터 인코딩입니다. 데이터를 어떻게 인코딩하냐에 대해서 ICS에서 요구하는 것은 특별히 없습니다만, (특정 상황에서 ‘추천’하는 것은 몇 개 있습니다) 뭘로 인코딩을 하든간에 Datagram이 상대 체인으로 전달 된 이후에 같은 방식으로 디코딩을 할 수 있어야 합니다. PoC와 같이 Foundry-Foundry의 경우에는 데이터 인코딩 스킴이 같기 때문에 큰 문제는 아니지만 서로 다른 체인간이라면 생각해봐야할 점이 있습니다. 체인 A와 B를 생각해봅시다.

  • 불투명한 Datagram: 어떤 오브젝트들은 불투명해도 됩니다. 대표적인 예시가_ Header_입니다. (다시한번, 이 Header는 블록체인의 헤더가 아닌 Client-02에 등장하는 Header입니다) Relayer가 A의 특정 높이에 대한 Header를 요청하면 A의 호스트는 이를 만들어서 줍니다. (Commitment-23의 함수를 호출하겠죠?) Relayer는 단순히 이 정보를 가지고 B로 간 다음, “이 정보가 뭔진 모르겠으나 니가 가지고있는 A의 라이트 클라이언트를 업데이트 하는데에 충분할거야”라는 의미로 트랜잭션에 raw한 형태로 써넣어서 제출합니다. 체인B의 엔진은 이를 받아 Client-02UpdateClient()를 호출하겠지만 그 순간까지에도 정보는 여전히 바이트 어레이로 남아있을 것입니다. 하지만 데이터가 Client-02의 영역으로 넘어간 순간 이 데이터는 디코딩이 자연스러워집니다. 왜냐하면 데이터를 인코딩한 체인A는 체인B에 있는 A에 대한 Client-02와 같은 주체이기 때문입니다. (미국 본국에서 러시아 내의 미국대사관에 대통령이 바뀌었다는 편지를 보낼 때 러시아어로 글을 쓸 필요가 없는 것과 같습니다.)

이는 데이터의 암호화나 보안의 문제는 아닙니다. 정 답답하면 Client-02외부의 다른 스펙이나 호스트에서 직접 상대 체인의 디코딩을 구현하여 내용을 확인해봐도 괜찮습니다. 하지만 원칙적으로 그럴 필요가 없게 설계가 되어 있고, 또한 상대 체인에 대한 검증은 모두 Client-02의 구현체에 몰아넣는 것이 깔끔한 소프트웨어 디자인이라고 생각해서, Foundry의 ICS 구현에서는 이와같은 디자인 원칙을 생각하고 구현하였습니다. Foundry의 Client-02의 구현체가 Commitment-23의 절반을 갖고 있는 것도 같은 이유에서입니다.

  • 투명한 Datagram: 어떤 Datagram들은 투명해야합니다. 즉 Relayer가 디코딩 한다음에 의미를 알 수 있어야 합니다. 예를들어 체인A의 사용자가 채널을 열고싶어 그에 대한 트랜잭션을 만든다고 해봅시다. 그 트랜잭션을 수행한 결과로 chan_open_init()이 호출이 되고 나면 '난 Channel을 열려고함'이라는 기록이 ChannelEnd라는 구조체의 형태로 스테이트에 남을 것입니다. ChannelEnd와 그에대한 머클증명을 Relayer가 가지고 체인B로 가져간 후, INIT의 다음단계인 chan_open_try()를 수행하는 트랜잭션을 만들어야하는데, 체인A에서 사용하는 인코딩 스킴을 알아야 ChannelEnd에 뭐라고 써져있는지를 알고 그에 따른 TRY요청을 B체인의 트랜잭션에 쓸 수 있습니다.

구현에 따라 이 디코딩을 Relayer가 직접할 수도 있고, Header와 마찬가지로 Client에게 요청할 수도 있습니다. Client가 자잘한 데이터들을 직접 디코딩 해주는건 ICS에서 요구하진 않지만, 상대 체인에 대해 구체적인 모든 내용들은 Client-02의 구현체에 들어가는게 위에서 설명한 개발책임의 분류상 적합하기 때문에 고려해볼 수 있는 방법입니다. PoC에서는 Foundry-Foundry이므로 간단하게 전자를 택했습니다.

  • 검증을 위한 인코딩: 예를들어, ConnectionChannel에서 특정 함수를 수행하다보면, Relayer가 전해준 정보로 부터 상대방이 어떤 의사를 표했다는 사실을 접하고 이를 검증하려는 로직이 자주 등장합니다. 예를 들어 체인 A에서 Channel을 열려고 했다고 생각해봅시다. Relayer는 그런 사실이 포함된 ChannelEnd의 디코딩된 값을 ChannelEnd의 머클증명과 함께 B에게 줬지만, 그 증명자체에 증명하는 값이 뭔지 포함되어 있지 않을 수도 있고, 설령 그렇다 해도 그걸 디코딩 할 수는 없으므로 실제로 내가 ‘기대하는’ A의 스테이트에 대한 증명인지는 알 수가 없습니다. 이 때 B의 chan_open_try()에서 Relayer가 디코딩해서 전해준 정보를 취합해서 만든 'A가 기록해놨길 기대하는 값'이 B의 내부 struct 표현방식으로는 존재할 겁니다. 이 때 B가 가지고 있는 A의 라이트 클라이언트에게 그 값을 전달해주면 A는 다시 A가 저장해놨을 형식으로 인코딩을 해서 머클검증을 수행합니다. 즉 'B속의 A의 라이트클라이언트'라는 특별한 지위를 이용하여 특정 구조체(ChannelEnd)의 A의 표현방식과 B의 표현방식을 모두 아는 상태에서 필요에 따라 인코딩을 한 후에 검증을 할 수 있게 되는 것입니다.

마찬가지로 PoC에서는 Foundry-Foundry이므로 굳이 이런 방식을 택할 필요는 없지만 (즉 B의 Client-02 외의 다른 스펙이 직접 A에 대한 인코딩을 수행하는 것에 별 어려움이 없습니다) 이러한 데이터의 구분을 위해 바이트로 인코딩하는 역할을 오롯이 Client-02의 verify함수들에게 남겼습니다.

종합하자면 Foundry의 ICS구현에서는 적어도 ClientCommitment외의 다른 Handler 구성요소들은 상대 체인의 데이터 인코딩 스킴에 대해 몰라도 되게 설계를 했으며, Client-02Relayer-18의 구현체에만 그러한 내용이 들어가도록 했습니다. 이는 최대한 Handler를 IBC를 하려는 체인과 상관없이 공통적으로 사용할 수 있는 제네릭한 모듈로 유지하려는 디자인 원리를 적용한 것입니다.

테스트

앞서 설명한 ICS 스펙들은 전체의 일부이지만 PoC를 해보는 데에는 충분합니다. Foundry팀은 실험용 브랜치에서 On-chain 스펙들은 Rust로, Relayer는 TypeScript로 구현하였으며 간단한 테스트 시나리오에서 작동을 확인할 수 있었습니다. Foundry의 ics-poc브랜치를 받아서 ibc.ts/README.md 파일을 보고 따라하면 Foundry 두 체인이 IBC패킷을 주고 받는 과정을 직접 볼 수 있습니다.

시나리오를 돌리기 위해서는 runChains, relayer, scenario의 세 스크립트를 돌려야 합니다. 입니다 runChains는 IBC를 통해 서로 통신할 두 Foundry 네트워크를 만듭니다. 먼저, 서로 다른 네트워크 아이디를 사용하는 Foundry 노드 두 개를 켭니다. 각 노드는 별도의 Foundry네트워크를 각자 구성하고, 그 네트워크의 유일한 참여자가 됩니다. 따라서 100%의 지분을 가지고 있고 홀로 위원회를 구성합니다.

scenario스크립트는 실제 유저의 행동을 나타냅니다. 각 체인에 라이트 클라이언트를 만드는 트랜잭션을 보내고, Connection 초기화 과정을 시작시키는 트랜잭션, 채널 초기화 과정을 시작시키는 트랜잭션, 패킷을 보내는 트랜잭션을 보냅니다. 위에서 설명한 Relayer-18에서 자동으로 생성되는 Datagram들이 아닌 그 외의 Handler호출들을 한다고 이해할 수 있습니다.

relayerRelayer의 구현체입니다. runChains가 동작시킨 두 체인으로부터 정보를 얻어 업데이트나 전달이 필요한 정보를 파악하고, 해당하는 트랜잭션을 만들어 각 체인에 보냅니다.

스펙 참여

아직 ICS는 초안 단계입니다. 그렇기에 아직 미완인 내용도 있고, 사소한 오류들도 꽤 존재합니다. Foundry팀에서는 PoC를 개발하면서 발견한 오류들을 수정하는 요청들을 지속적으로 건의했고, 이들은 실제로 반영되었습니다. 이런식으로 Foundry팀은 ICS에 지속적으로 기여를 하고 있습니다.

결론

이렇게 Codechain Foundry팀의 지난 ICS 구현에 대한 소개가 끝났습니다. 정리하자면 Foundry는 ICS을 지원 할 예정이고 이에 대한 사전조사 차원에서 실험적인 PoC 구현을 마쳤습니다. ICS는 복잡한 스펙이므로 구성요소들을 하나하나 나누어 어떤 의미를 가지고 있는지, 또한 Foundry는 이를 어떻게 구현했는지와 함께 설명해드렸습니다. 또한 구현하면서 헷갈리거나 어려웠던 점들도 공유해보았습니다.

이 글을 읽으신 분들은 ICS의 원리에 대해 이해함은 물론이고 Foundry가 IBC 생태계에 참여하여 블록체인의 새로운 지평을 여는 일에 동참하고 있다는 점을 알게 되셨을겁니다!

ICS 구현체의 실제 코드는 다음 링크에서 확인하실 수 있습니다. https://github.com/CodeChain-io/foundry/tree/ics-poc/core/src/ibc

--

--