IBC(ブロックチェーン間通信)の概要
はじめに
前提知識
- PoW(Proof of Work), PoS(Proof of Stake)のざっくりとした知識
- マークル木とマークル証明についてのざっくりとした知識
この記事で扱うこと
- IBCとは何か?
- IBCのセキュリティ
- IBCのざっくりとした動作原理・規格
- ローカルチェーンとパブリックチェーンをIBCでつなげる方法
IBCとは
IBC(Inter-Blockchain Communication, ブロックチェーン間通信)とは、2つのステートマシン間で任意のデータをやり取りするための通信プロトコルです。
主にCosmos Networkでのトークン転送に利用されますが、Cosmos Networkに限らず、さまざまなブロックチェーンで(更に言うと、ブロックチェーンに限らずステートマシンであれば)IBCを利用することができます。
チェーンAとチェーンBがIBCを用いて通信を行う際、通信を仲介するリレイヤーと呼ばれるノードが必要です。リレイヤーはチェーンA(B)の情報をクエリし、チェーンB(A)にトランザクションを送るということを繰り返すことでチェーン間の通信を仲介します。特筆すべきは、リレイヤーを一切信頼する必要がないということです。リレイヤーのうち、一つでも善良なノードがあればIBCは正しく動きます。リレイヤーを行うすべてのノードに悪意があっても、最悪IBCが停止するだけです。
ブロックチェーン間の通信はリレイヤーが仲介する。
IBCのこのトラストレスな性質はどのようにして可能になっているのでしょうか?
それはチェーンA(B)が、チェーンB(A)の簡易ノード(ライトクライアント)を内部で立ち上げ、リレーされてきたチェーンB(A)のデータの正当性を検証することで可能になっています。
ライトクライアントとは
フルノードはブロックチェーンのすべてのデータを保存するため、非常に大きなリソースが必要です。例えば、Cosmos-hubというブロックチェーンでは、フルノードを動かすために現在1TB以上の保存領域が必要になります。
一方でライトクライアントは、主にブロックヘッダー(ブロックのハッシュ値)のみを保存するので非常に軽量です。あるトランザクションがブロックに含まれるかどうかは、マークル証明[1]を利用してブロックヘッダーだけで検証することができるので、ライトクライアントでも証明さえ提示されればトランザクションがチェーン内に取り込まれているかどうかが分かります。
ライトクライアントは、ある信頼できるブロックヘッダーとブロック高[2]を起点として、それ以降の
- ブロックヘッダー
- ブロック高
- バリデータ(PoSコンセンサスに参加しているノード)の公開鍵とそのVoting power(ステークした量に依存した投票力)のペア(バリデータセット)。
- Commit (バリデータのブロックヘッダーに対する署名)
- 次ブロックでのバリデータセットのハッシュ値
を順次ダウンロードします[3]。
あるブロック高n
での上記の情報を信用するなら、n+1
における情報は次のように検証することができます。
- ブロック高
n
での「次ブロックのバリデータセットのハッシュ値」がブロック高n+1
でのバリデータセットのハッシュ値に等しいことを確認する。これによってブロック高n+1
でのバリデータセットが正しいことが保証される。 - ブロック高
n+1
のCommitが正しく、十分な投票(全投票力の2/3以上)が集まっていることを確認する。これによってブロック高n+1
のブロックヘッダーが、コンセンサスの取れた正しいものであることが保証される。 - マークル証明を用いて、ブロック高
n+1
における「次ブロックでのバリデータセットのハッシュ値」が正しいことを証明する。
これを帰納的に繰り返すことで、現在までの正しいブロックヘッダーのシークエンスを得ることができます。このプロセスは情報源の信頼性に依存しません。すなわち、最初に入力したブロックヘッダーとブロック高さえ信頼すれば、赤の他人から上記の情報を教えてもらったとしても、得られたブロックヘッダーのシークエンスを信頼することができます。
ただし、この方式では通信回数が多いので、Tendermintライトクライアント(IBCで最もよく用いられるライトクライアント)では検証を効率的にスキップすることで通信回数を抑えているようです。詳しくは下の記事をご覧ください。
IBCでは両方のチェーンが相手チェーンのライトクライアントを立ち上げることで、相手のチェーン状態の検証を行います。
両方のチェーンが相手チェーンのライトクライアントを立ち上げる。
IBCの大まかな流れ
IBCを用いてチェーンAからチェーンBにトークンを転送する際は、大まかに次のような流れで行われます。
- チェーンA, Bはリレイヤーを介して、それぞれが相手チェーンのライトクライアントインスタンスを立ち上げたのち、ハンドシェイクを行いコネクションを確立する。
- 両チェーンの
transfer
ポートを接続するチャンネルを、ハンドシェイクを行い開く。 - チェーンA上の利用者は、チェーンB上にトークンを送金したいという旨のトランザクションをチェーンAに送る。送金予定のトークンはロックされる。
- リレイヤーはチェーンAの状態変化を察知し、そのマークル証明が含まれたトランザクションをチェーンBへ送る。
- チェーンBはライトクライアントを用いてマークル証明を検証し、確認が取れたら送金された額と同額のバウチャートークンを発行する。
コネクション, チャンネル, ポートとは
コネクションはIBCの基礎レイヤーですが、ブロックチェーンアプリケーションはその一つ上のレイヤーであるチャンネルを介して通信を行います。TCP/IPに例えれば、コネクションはトランスポート層、チャンネルはアプリケーション層に対応します。
IBCでは、コネクションが属するレイヤーをIBC:TAO (IBC Transport, Authentication, and Ordering of packets), チャンネルが属するレイヤーをIBC:APP(IBC Application)と呼びます。
アプリケーションのモジュールごとに固有のport-id
が割り当てられており、例えばトークンを扱うモジュールbank
のport-id
はtransfer
と決められています。チャンネルは両チャンネルのポート同士を接続します。コネクションやチャンネルのオープンは初回通信時にのみ行われます。その際、立ち上げられたライトクライアントインスタンスには固有のclient-id
が、コネクションにはconnection-id
が、チャンネルにはchannel-id
が付与され、(リレイヤーが変わっても)永久に変わることがありません。
注意として、チャンネルのIDchannel-id
はチェーンAとチェーンBで独立に割り当てられるので、基本的に一致しません。
コネクション、ポート、チャンネルの概念図。
バウチャートークンとは
バウチャー(受領証明書などの意味)トークンとは、同額のトークンがチェーンAでロックされていることを証明する代替性トークンです。チェーンBからチェーンAに送り返す際には、バウチャートークンはバーンされ、それと同額のトークンがチェーンAでアンロックされます。
IBCはトラストレスなプロトコルですが、チェーンAとチェーンBへの信頼は仮定しています。したがって、チェーンAが実はロックしなかったとか、チェーンBが実はバーンしなかったなどの場合はIBCは破綻します。しかしながら、両チェーンへの信頼はブリッジプロトコルとして必要最低限の仮定です。信頼できないブロックチェーンは、そもそも使うべきではありません。
正しいチェーンと接続していることの確認
ブロックチェーンはchain-id
と呼ばれる識別子を持っているのですが、既存のチェーンと同じchain-id
を持つチェーンは簡単に作成することができます。したがって、接続先のチェーンの真偽はchain-id
ではなくchannel-id
(あるいはconnection-id
やclient-id
)を用いて判断する必要があります。では本物のチェーンのchannel-id
はどのように調べることができるのでしょうか?
残念なことに、現時点ではブロックエクスプローラーなどで調べるか、両方のチェーンのノードを立ててチェックするしかないようです。この方法には不満があるので、将来的にはCosmos-hubにCNS(Chain Name Service)をホストし、分散的に管理することが検討されているようです。
IBCの詳細な流れ
コネクションの確立
ライトクライアントインスタンスの生成と更新
コネクションの確立に先立ち、両チェーンのCreateClient
メソッドを呼び出し、ライトクライアントインスタンスを生成する必要があります。
CreateClient
はclient-id
をレスポンスとして返します。client-id
は07-tendermint-831
のような形式を取ります。この場合、07-tendermint
はライトクライアントのバージョンで831
はclientインスタンスのIDです。
UpdateClient
メソッドでclientの状態を更新し、最新のブロックに同期することができます。
ハンドシェイク
コネクションのオープンはTCPと同じようにハンドシェイクによって行われます。
ConnOpenInit
コネクションのプロセス開始をチェーンAに告知するためのメソッドです。client-id
を引数として渡すと、 connectionID
が新たに割り当てられ、返り値として受け取ります。チェーンAはINIT
状態になります。
func (k Keeper) ConnOpenInit(
ctx sdk.Context,
clientID string,
counterparty types.Counterparty, // counterpartyPrefix, counterpartyClientIdentifier
version *types.Version,
delayPeriod uint64,
) (string, error) {...
ConnOpenTry
「チェーンAがINIT
状態になり、〇〇というconnectionID
が割り当てられた」ということをチェーンBがライトクライアントで検証するためのメソッドです。チェーンB側でもconnectionID
が新たに割り当てられ、返り値で返します。チェーンBはTRYOPEN
状態になります。このメソッドを呼ぶ前にリレイヤーはUpdateClient
メソッドを両チェーンに対して呼び、ライトクライアントを最新の状態に更新する必要があります。
func (k Keeper) ConnOpenTry(
ctx sdk.Context,
counterparty types.Counterparty, // counterpartyConnectionIdentifier, counterpartyPrefix and counterpartyClientIdentifier
delayPeriod uint64,
clientID string, // clientID of chainA
clientState exported.ClientState, // clientState that chainA has for chainB
counterpartyVersions []exported.Version, // supported versions of chain A
proofInit []byte, // proof that chainA stored connectionEnd in state (on ConnOpenInit)
proofClient []byte, // proof that chainA stored a light client of chainB
proofConsensus []byte, // proof that chainA stored chainB's consensus state at consensus height
proofHeight exported.Height, // height at which relayer constructs proof of A storing connectionEnd in state
consensusHeight exported.Height, // latest height of chain B which chain A has stored in its chain B client
) (string, error) {...
ConnOpenAck
「チェーンBがINIT
状態になり、〇〇というconnectionID
が割り当てられた」ということをチェーンAがライトクライアントで検証するためのメソッドです。チェーンAはOPEN
状態になります。このメソッドを呼ぶ前にリレイヤーはUpdateClient
メソッドを両チェーンに対して呼び、ライトクライアントを最新の状態に更新する必要があります。
func (k Keeper) ConnOpenAck(
ctx sdk.Context,
connectionID string,
clientState exported.ClientState, // client state for chainA on chainB
version *types.Version, // version that ChainB chose in ConnOpenTry
counterpartyConnectionID string,
proofTry []byte, // proof that connectionEnd was added to ChainB state in ConnOpenTry
proofClient []byte, // proof of client state on chainB for chainA
proofConsensus []byte, // proof that chainB has stored ConsensusState of chainA on its client
proofHeight exported.Height, // height that relayer constructed proofTry
consensusHeight exported.Height, // latest height of chainA that chainB has stored on its chainA client
) error {...
ConnOpenConfirm
「チェーンAがOPEN
状態になった」ということをチェーンBに伝えるためのメソッドです。チェーンBはOPEN
状態となり、コネクションが確立されます。
func (k Keeper) ConnOpenConfirm(
ctx sdk.Context,
connectionID string,
proofAck []byte, // proof that connection opened on Chain A during ConnOpenAck
proofHeight exported.Height, // height that relayer constructed proofAck
)...
チャンネルのオープン
チャンネルのオープンはコネクションの確立と同様にChannelOpenInit
, ChannelOpenTry
, ChannelOpenAck
, ChannelOpenConfirm
によるハンドシェイクで行います。コネクションとの違いは、port-id
を指定する必要があること程度です。
詳しくは
をご参照ください。
チャンネルによるパケット送信
チャンネルが開かれると、アプリケーションは通信を行うことが可能になります。通信はIBC/TAO層が行ってくれるので、アプリケーションは通信そのものを実装する必要はなく、代わりに{port-id}/{channel-id}
で指定されるパスにパケットをコミットすることでIBC/TAO層に通信が必要であることを伝えます。
パケットとは
パケットとは、一回のIBCリレーで伝達されるデータの塊のことです。適切なアプリケーションへ伝達するためのルーティング(ポートやチャンネル)や、タイムアウトを含みます。
func NewPacket(
data []byte,
sequence uint64, sourcePort, sourceChannel,
destinationPort, destinationChannel string,
timeoutHeight clienttypes.Height, timeoutTimestamp uint64,
) Packet {
return Packet{
Data: data,
Sequence: sequence,
SourcePort: sourcePort,
SourceChannel: sourceChannel,
DestinationPort: destinationPort,
DestinationChannel: destinationChannel,
TimeoutHeight: timeoutHeight,
TimeoutTimestamp: timeoutTimestamp,
}
}
パケットの流れ
パッケットは下の図のように流れます。実線は状態変化を伴う関数の実行を、点線は状態変化を伴わないクエリを表しています。
パケットの流れ
SendPacket
1. パケットを{port-id}/{channel-id}
で指定されるパスへコミットするメソッドです。
func (k Keeper) SendPacket(
ctx sdk.Context,
channelCap *capabilitytypes.Capability,
packet exported.PacketI,
) error {
QueryPacket
2. リレイヤーがコミットされたパケットを読み取るためのメソッドです。
RecvPacket
3. リレイヤーがチェーンBにパケットを渡すためのメソッドです。
func (k Keeper) RecvPacket(
ctx sdk.Context,
chanCap *capabilitytypes.Capability,
packet exported.PacketI,
proof []byte,
proofHeight exported.Height,
) error {
onRecvPacket
4. 受信したパケットはモジュールにルーティングされ、このメソッドが呼び出されます。
WriteAcknowledgement
5. もしチェーンAにacknowledgementを返す必要があれば、このメソッドが呼ばれ、acknowledgeパケットが{port-id}/{channel-id}
にコミットされます。
func (k Keeper) WriteAcknowledgement(
ctx sdk.Context,
chanCap *capabilitytypes.Capability,
packet exported.PacketI,
acknowledgement exported.Acknowledgement,
) error {
QueryAcknowledgement
6. リレイヤーがチェーンBのacknowledgeパケットを読み取るためのメソッドです。
AcknowledgePacket
7. リレイヤーがチェーンAにacknowledgeパケットを渡すためのメソッドです。
func (k Keeper) AcknowledgePacket(
ctx sdk.Context,
chanCap *capabilitytypes.Capability,
packet exported.PacketI,
acknowledgement []byte,
proof []byte,
proofHeight exported.Height,
) error {
onAcknowledgePacket
8. acknowledgeパケットは元のモジュールにルーティングされ、このメソッドが呼ばれます。
タイムアウトになった場合の処理
パケットに指定された期限を過ぎてもリレーされない場合はタイムアウトとなります。タイムアウトの場合はリレイヤーがチェーンBにリレーされなかったことの証明をチェーンAに提出することで、チェーンA側の処理をキャンセルします。
タイムアウトになった場合
TimeoutPacket
タイムアウトになった場合にリレイヤーが呼び出すメソッドです。パスにコミットされたパケットを削除します。
func (k Keeper) TimeoutPacket(
ctx sdk.Context,
packet exported.PacketI,
proof []byte,
proofHeight exported.Height,
nextSequenceRecv uint64,
) error {
onTimeoutPacket
タイムアウトになった場合に呼ばれるモジュールのメソッドです。IBC送金の場合はトークンのロックを解除し、送金者に返送するなどのロジックを実装します。
ローカルチェーンとパブリックチェーンを接続する
Igniteを利用してローカルチェーンを作成し、パブリックチェーン(cosmos-hub theta testnet)に接続してみます。Cosmos-hunメインネットとも同様の手順で接続できますが、トランザクション手数料が馬鹿にならない(0.3atom程度かかる)のでテストネットでまず試すのがおすすめです。
Igniteのインストール
以下のページなどを参考に、ignite(ブロックチェーンを作ることができるツール)をインストールしてください。
Igniteを動かすにはGolangが必要です。
まだインストールされていない方は、以下のページなどを参考にインストールしてください。
また、GOPATH
をPATHに追加する必要があります。
export GOPATH=$(go env GOPATH)
export PATH=$PATH:$(go env GOPATH)/bin
ブロックチェーンの作成
ignite scaffold chain
コマンドでチェーンの雛形を作りましょう。--address-prefix
でアドレスのプレフィックスを指定できます。指定しない場合cosmos
になりますが、今回はtestnetのアドレスと区別をつけやすくするため、指定することをおすすめします。
ignite scaffold chain mylocalchain --address-prefix my
mylocalchain/
ディレクトリに次のような内容のconfig.yml
が生成されているはずです。
accounts:
- name: alice
coins: ["20000token", "200000000stake"]
- name: bob
coins: ["10000token", "100000000stake"]
validator:
name: alice
staked: "100000000stake"
client:
openapi:
path: "docs/static/openapi.yml"
vuex:
path: "vue/src/store"
faucet:
name: bob
coins: ["5token", "100000stake"]
config.yml
はgenesis.json
の内容をyml
形式で指定することができます。genesis.json
はブロックチェーンのパラメータを指定するファイルで、チェーン誕生時に誰がどれだけのトークンを持っているかなども指定することができます。この場合、Aliceは20000token
と200000000stake
、Bobは10000token
と100000000stake
持つことが分かります。
この状態でignite chain serve
を実行すると、ブロックチェーンがビルドされ起動します。簡単ですね!
~/D/d/mylocalchain ❯❯❯ ignite chain serve
Cosmos SDK's version is: stargate - v0.45.4
🔄 Resetting the app state...
🛠️ Building proto...
📦 Installing dependencies...
🛠️ Building the blockchain...
💿 Initializing the app...
🙂 Created account "alice" with address "my19m8xlmnjduvp0xdd5afq4x7dsu7g05vraajmda" with mnemonic: "junior metal supreme list close retreat token fish leg version legal canvas raven behave drip label memory awful main pretty proof lesson silver aim"
🙂 Created account "bob" with address "my1g22und2uf9lrf6hwv49eejunyr9jq85q3xd96c" with mnemonic: "add south brief outside rural display young token wait virus better smoke client symbol lamp meat note wet fragile submit now kitchen people wall"
🌍 Tendermint node: http://0.0.0.0:26657
🌍 Blockchain API: http://0.0.0.0:1317
🌍 Token faucet: http://0.0.0.0:4500
同時に$GOPATH/bin
にブロックチェーン名+d
のCLIツールがインストールされます。別のターミナルを開き、mylocalchaind keys list
で登録された鍵を見てみましょう。
~ ❯❯❯ mylocalchaind keys list
- name: alice
type: local
address: my19m8xlmnjduvp0xdd5afq4x7dsu7g05vraajmda
pubkey: '{"@type":"/cosmos.crypto.secp256k1.PubKey","key":"A10eltvKGC7ZgpmT4E0p6SPW0qcw0eGnN7IEkfIlb58p"}'
mnemonic: ""
- name: bob
type: local
address: my1g22und2uf9lrf6hwv49eejunyr9jq85q3xd96c
pubkey: '{"@type":"/cosmos.crypto.secp256k1.PubKey","key":"Av9TYS4DhA/JuFCa7SjEGVrUJfrToaY8ihsuDWT5lyLj"}'
mnemonic: ""
mylocalchaind
の操作方法は、Cosmosh-hubのCLIツールgaiad
とほぼ同じなので、そちらのドキュメンを参考にするのがおすすめです。
リレイヤーの構成
リレイヤーの機能もignite
が提供しています。
ignite relayer configure
でリレイヤーを構成しましょう。基本的にデフォルトの設定で大丈夫ですが、Target RPC
はtestnetを指定したいので、testnetのRPChttps://rpc.sentry-01.theta-testnet.polypore.xyz:443
を指定しておきます。Source Address Prefix
も作成したブロックチェーンのプレフィックスに合わせておきます。Source Faucet
はhttp://0.0.0.0:4500
を指定します。
~ ❯❯❯ ignite relayer configure
------
Setting up chains
------
? Source Account default
? Target Account default
? Source RPC http://localhost:26657
? Target RPC https://rpc.sentry-01.theta-testnet.polypore.xyz:443 <---変更する!
? Target Faucet (optional)
? Source Gas Price 0.00025stake
? Target Gas Price 0.025uatom
? Source Gas Limit 300000
? Target Gas Limit 300000
? Source Address Prefix my <---変更する!
? Target Address Prefix cosmos
🔐 Account on "source" is default(my1juzrpdf8dfgsn9e7wq0tjdputcfx3re0lpw0ml)
received coins from a faucet
|· (balance: 100000stake,5token)
🔐 Account on "target" is default(cosmos1juzrpdf8dfgsn9e7wq0tjdputcfx3re0hmqacn)
no faucet available, please send coins to the address
|· (balance: -)
⛓ Configured chains: mylocalchain-theta-testnet-001
※リレイヤーを再構成する場合は、rm -rf ~/.ignite/relayer/
で前回のデータを削除しておかないとおかしくなるみたいです。
トランザクション手数料の入金
ローカルチェーン側のアカウントはmy1juzrpdf8dfgsn9e7wq0tjdputcfx3re0lpw0ml
で、パブリックチェーン側のアカウントはcosmos1juzrpdf8dfgsn9e7wq0tjdputcfx3re0hmqacn
であることが分かりました(もちろん実行環境によってこの値は変わります)。リレイヤーになるためにはこれらのアカウントに、トランザクション手数料を支払うのに十分な資金がないといけません。
ローカルチェーン側
Source Faucet
を指定しておくと自動的に入金されますが、もし設定ミスなどで入金されていない場合は、Aliceのアカウントから送金します。
mylocalchaind tx bank send alice my1juzrpdf8dfgsn9e7wq0tjdputcfx3re0lpw0ml 100000stake -y
パブリックチェーン側
Cosmos NetworkのDiscordの#testnet-faucetチャンネルにfaucetがあります。
リレイヤーを立ち上げる
ignite relayer connect
でリレイヤーを立ち上げます。
~ ❯❯❯ ignite relayer connect
------
Paths
------
mylocalchain-theta-testnet-001:
mylocalchain > (port: transfer) (channel: channel-0)
theta-testnet-001 > (port: transfer) (channel: channel-508)
------
Listening and relaying packets between chains...
------
Block exploreで見てみましょう。
ハンドシェイクの結果、チャンネルが開かれたのが見えました!
パブリックチェーンへ送金
別のターミナルウィンドウで、mylocalchaind tx ibc-transfer transfer [src-port] [src-channel] [receiver] [amount]
を実行し、送金を行います。
mylocalchaind tx ibc-transfer transfer transfer channel-0 cosmos1juzrpdf8dfgsn9e7wq0tjdputcfx3re0hmqacn 10000stake --from alice
クライアントが更新されたのち、トークンを受け取っていることが分かります。
IBCを利用したブロックチェーンアプリケーションの実装方法に関しては, igniteのチュートリアルがおすすめです。https://docs.ignite.com/guide/ibc
参考にした記事
-
解説記事https://prsarahevans.com/page-629/page-656/などをご参照ください ↩︎
-
ブロックエクスプローラーで調べたり、複数のノードにクエリすることで取得します。これはPoSチェーンの「弱い主観性」と呼ばれる性質から手動で行う必要があります。複数のフォークが存在する場合、PoWでは、現在のブロックまでに最も多くの計算リソースを消費したチェーンが「正統な」チェーンであるという客観的基準が存在するため、初めてノードを立ち上げた場合でも自動的に「正統な」チェーンと同期されますが、PoSではそのような客観的基準が存在せず、どのチェーンが「正統」かはソーシャルメディアなどで得られる「主観的な」情報に依存します。ただし、一度「正統な」チェーンを選べば、ハードフォークが起きるまではノードが自動的に「正統な」チェーンを選び続けるという意味で客観性があるため「弱い主観性」と呼ばれています。詳しくはhttps://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivityをご参照ください ↩︎
-
他のフィールドも取得します。tendermintライトクライアントが実際にどのようなフィールドを取得するかはhttps://github.com/tendermint/spec/tree/master/spec/light-client/verificationをご参照ください ↩︎
Discussion