概述

本文档描述了一种多跳 IBC 通道标准。多跳通道规定了一种消息路由方式,可利用多个预先存在的 IBC 连接,在一条由支持 IBC 的区块链组成的路径上转发消息。

动机

当前的 IBC 协议以点对点范式定义消息传递,允许两个直接连接的 IBC 链之间进行消息传输;但随着越来越多支持 IBC 的链出现,跨链中继 IBC 数据包的需求也随之产生,因为希望交换消息的两条链之间可能并不存在 IBC 连接。IBC 连接可能因多种原因而不存在,其中之一可能是经济上不可行,因为连接要求在连接两端之间持续交换客户端状态,这会带来成本。

定义

相关定义在先前引用的标准中已有说明(在适用情况下,函数也定义于其中)。 Connection 的定义见 ICS 3。 Channel 的定义见 ICS 4。 Channel Path 定义为通道所定义的连接 ID 路径。 Connection Hop 定义为通道路径上两条链之间连接的连接 ID。

期望属性

  • IBC 通道握手和消息数据包应能够利用预先存在的连接,形成一条逻辑证明链,以在未直接连接的链之间中继消息。
  • 多跳 IBC 通道的中继不应要求在中间跳上写入额外的通道、数据包或超时状态。
  • 该设计应尽量减少生成和查询多跳证明所需的客户端更新次数。
  • 尽量少增加必要状态,并尽量少修改核心 IBC 规范与应用 IBC 规范。
  • 保留连接、通道和数据包定义的期望属性。
  • 保持单跳连接上传递消息的向后兼容性。

技术规范

本规范的大部分内容描述了多跳证明的生成与验证。IBC 连接本身保持不变。此外,通道握手与数据包消息类型,以及通用的往返消息语义与流程也将保持不变。额外工作主要发生在接收链上的验证方,以及需要查询证明的中继者一侧。 跨多个跳传递的消息,需要提供从源链到接收链的连接路径证明,以及源链上的数据包承诺证明。连接路径的证明方式,是验证路径中每条连接的连接状态和共识状态,直到接收链为止。从高层次看,这可以理解为沿通道路径串联起来的一组证明,使接收链能够从与其最终客户端关联的共识状态开始,逐步证明通道路径中的每个连接及其共识状态,从而证明源链上的某个键值。后续的每个共识状态和连接都会依次被证明,直到源链的共识状态被证明出来,随后即可用它来证明源链上的目标键值。

假设

本多跳规范假设各条链用于成员验证的所有证明规范都是相同的。

通道握手与数据包消息

对于通道握手和数据包消息,额外的连接跳定义在预先存在的 connectionHops 字段中。通道路径上的连接必须处于 OPEN 状态,以保证消息能够投递给正确的接收方。更多信息参见 Path Forgery Protection。 有关多跳相关的规范变更,参见 ICS 4。多跳不会改变现有规范中关于通道握手、数据包投递和超时处理的行为。不过,多跳通道需要对冻结客户端进行特殊处理(参见 chanCloseFrozen)。 在连接拓扑方面,用户可以利用 chain registry 中的信息,确定一条从发送方 -> 接收方的可行通道路径。他们也可以通过网络查询独立验证这些信息。

多跳中继

中继者像当前一样投递通道握手和 IBC 数据包,但需要额外提供通道路径证明。中继者会扫描数据包事件中的 connectionHops 字段,并通过检查该字段中的跳数来判断该数据包是否为多跳数据包。如果跳数大于一,则该数据包是多跳数据包,需要额外的证明数据。 对于每个多跳通道(详细证明逻辑见下文):
  1. 扫描源链上的待中继 IBC 消息。
  2. 从扫描到的消息中读取 connectionHops 字段,以确定通道路径。
  3. 通过 chain registry 配置中的连接端点,查询所需的多跳证明高度,并在必要时更新通道路径上的客户端状态(参见伪代码实现)。
  4. 在第 3 步所使用的证明高度上,查询源链上的数据包或握手消息承诺证明。
  5. 使用第 3 步确定的证明高度,查询通道路径中每个中间连接的连接证明和共识状态证明。
  6. 将证明和数据提交到接收链上的 RPC 端点。
中继者具备连接拓扑感知能力,其配置来源于 chain registry。

证明生成与验证

proof_generation.png 证明生成的图示。 relayer_calc_update_heights.png 在查询多跳证明的第一阶段,中继器会搜索能够证明前一条链状态、且也可由通道路径中的下一条链证明的最小共识高度。 relayer_query_proof_and_submit.png 在查询多跳证明的第二阶段,中继器会在第一阶段确定的高度上,查询客户端状态和连接状态的证明,以及源链上的键/值。 proof_query_algorithm.png 多跳证明查询算法。 proof_verification.png 多跳证明验证逻辑。 针对包含 N 条链的通道路径的证明生成伪代码:C[0] --> C[i] --> C[N]

// Proof generation helper functions
//
// Note: 'Chain' is assumed to contain information about the next chain in the channel path
//
// GetClientID returns the clientID for the next chain in the channel path
func (Chain) GetClientID() (clientID string)
// GetConnectionID returns the connectionID corresponding to the next chain in the channel path
func (Chain) GetConnectionID() (connectionID string)
// GetClientStateHeight returns the client state height for the clientState corresponding to
// the next chain in the channel path
func (Chain) GetClientStateHeight() exported.Height
// QueryStateAtHeight returns the value and proof of a key at the given height along with the height at
// which the proof will succeed.
func (Chain) QueryStateAtHeight(key string, height int64) (value []byte, proof []byte, height exported.Height)
// UpdateClient updates the client state corresponding to the next chain in the channel path
func (*Chain) UpdateClient()

// ProofHeights contains multi-hop proof query height data.
type ProofHeights struct {
    proofHeight     exported.Height // query the proof at this height
    consensusHeight exported.Height // the proof is for the consensusState at this height
}

// ProofData is a generic proof struct.
type ProofData struct {
    Key   *MerklePath
    Value []byte
    Proof []byte
}

// MultihopProof defines a set of proofs to verify a multihop message.
// Consensus and Connection proofs are ordered from receiving to sending chain but do not include
// the chain[1] consensus/connection state on chain[0] since it is already known on the receiving chain.
type MultihopProof struct {
    KeyProof *ProofData            // the key/value proof on the on chain[KeyProofIndex] in the channel path
    ConsensusProofs []*ProofData   // array of consensus proofs starting with proof of consensusState of chain[1] on chain[2]
    ConnectionProofs []*ProofData  // array of connection proofs starting with proof of conn[1,2] on chain[2]
}

// QueryMultihopProof generates proof of a key/value at the proofHeight on indexed chain (chain0).
// Chains are provided in order from the sending (source) chain to the receiving (verifying) chain.
func QueryMultihopProof(
    chains []*Chain,
    key string,
    keyHeight exported.Height,
    includeKeyValue bool,
) (
    multihopProof MultihopProof,
    multihopProofHeight exported.Height,
) {

    abortTransactionUnless(len(chains) > 1)

    // calculate proof heights along channel path
    proofHeights := make([]*ProofHeights, len(chains)-1)
    abortTransactionUnless(calcProofHeights(chains, 1, keyHeight, proofHeights))

    // the consensus state height of the proving chain's counterparty
    // this is where multi-hop proof verification begins
    multihopProofHeight = abortTransactionUnless(proofHeights[len(proofHeights)-1].consensusHeight.Decrement())

    // the key/value proof height is the height of the consensusState on the source chain
    keyHeight = abortTransactionUnless(proofHeights[0].consensusHeight.Decrement())

    var value []byte
    bytes, keyProof := chains[0].QueryStateAtHeight(key, keyHeight)

    if includeKeyValue {
        value = bytes
    }

    // assign the key/value proof
    multihopProof.KeyProof = &ProofData{
        Key:   nil,    // key to prove constructed during verification
        Value: value,  // proven values are constructed during verification (except for frozen client proofs)
        Proof: keyProof,
    }

    // query proofs of consensus/connection states on intermediate chains
    multihopProof.ConsensusProofs = make([]*ProofData, len(chains)-2)
    multihopProof.ConnectionProofs = make([]*ProofData, len(chains)-2)
    multihopProof.ConsensusProofs, multihopProof.ConnectionProofs = abortTransactionUnless(
        queryIntermediateProofs(
            chains,
            len(chains)-2,
            proofHeights,
            multihopProof.ConsensusProofs,
            multihopProof.ConnectionProofs)
        )

    return
}

// calcProofHeights calculates the optimal proof heights to generate a multi-hop proof
// along the channel path and performs client updates as needed.
func calcProofHeights(
    chains []*Chain,
    chainIdx int,
    consensusHeight exported.Height,
    proofHeights []*ProofHeights,
) {
    var height ProofHeights
    chain := chains[chainIdx]

    // find minimum consensus height provable on the next chain
    // i.e. proofHeight is the minimum height at which the consensusState with
    // height=consensusHeight can be proved on the chain (aka processedHeight)
    height.proofHeight, height.consensusHeight = abortTransactionUnless(queryMinimumConsensusHeight(chain, consensusHeight, nil))

    // if no suitable consensusHeight then update client and use latest chain height/client height
    //
    // TODO: It could be more efficient to update the client with the missing block height
    // rather than the latest block height since it would be less likely to need client updates
    // on subsequent chains.
    if height.proofHeight == nil {
        abortTransactionUnless(chain.UpdateClient())
        height.proofHeight = chain.GetLatestHeight()
        height.consensusHeight = chain.GetClientStateHeight(chains[chainIdx+1])
    }

    // stop on the next to last chain
    if chainIdx == len(chains)-2 {
        proofHeights[chainIdx-1] = &height
        return
    }

    // use the proofHeight as the next consensus height
    abortTransactionUnless(calcProofHeights(chains, chainIdx+1, height.proofHeight, proofHeights))

    proofHeights[chainIdx-1] = &height
    return
}

// queryIntermediateProofs recursively queries intermediate chains in a multi-hop channel path for consensus state
// and connection proofs. It stops at the second to last path since the consensus and connection state on the
// final hop is already known on the destination.
func queryIntermediateProofs(
    chains []*Chain,
    proofIdx int,
    proofHeights []*ProofHeights,
    consensusProofs []*ProofData,
    connectionProofs []*ProofData,
) {
     // no need to query proofs on final chain since the clientState is already known
    if proofIdx < 0 {
        return
    }

    chain := chains[proofIdx]
    ph := proofHeights[proofIdx]

    // query proof of the consensusState
    proof := abortTransactionUnless(queryConsensusStateProof(chain, ph.proofHeight, ph.consensusHeight))
    consensusProofs[len(p.Paths)-proofIdx-2] = proof

    // query proof of the connectionEnd
    proof = abortTransactionUnless(queryConnectionProof(chain, ph.proofHeight))
    connectionProofs[len(p.Paths)-proofIdx-2] = proof

    // continue querying proofs on the next chain in the path
    queryIntermediateProofs(chains, proofIdx-1, proofHeights, consensusProofs, connectionProofs)
}

// Query a proof for the counterparty consensus state at the specified height on the given chain.
func queryConsensusStateProof(
    chain Chain,
    proofHeight exported.Height,
    consensusHeight exported.Height,
) *ProofData {

    key := host.FullConsensusStateKey(chain.GetClientID(), consensusHeight)
    consensusStateBytes, consensusStateProof := chain.QueryStateAtHeight(key, int64(proofHeight.GetRevisionHeight()))
    merklePath := abortTransactionUnless(chain.GetMerklePath(string(key)))

    return &ProofData{
        Key:   merklePath,
        Value: consensusStateBytes,
        Proof: consensusStateProof,
    }
}

// Query a proof for the connEnd on the given chain at the specified height.
func queryConnectionProof(
    chain Chain,
    proofHeight exported.Height,
) *ProofData {

    key := host.ConnectionKey(chain.GetConnectionID())
    connectionEndBytes, connectionEndProof := chain.QueryStateAtHeight(key, int64(proofHeight.GetRevisionHeight()))
    merklePath := abortTransactionUnless(chain.GetMerklePath(string(key)))

    return &ProofData{
        Key: merklePath,
        Value: connectionEndBytes,
        Proof: connectionEndProof,
    }
}

// queryMinimumConsensusHeight returns the minimum height within the provided
// range at which a valid consensusState exists (processedHeight) and the
// corresponding consensus state height (consensusHeight).
func queryMinimumConsensusHeight(
    chain Chain,
    minConsensusHeight exported.Height,
    limit uint64,
) (
    processedHeight exported.Height,
    consensusHeight exported.Height
) {

    // find the minimum height consensus state
    consensusHeight := minConsensusHeight
    for i := uint64(0); i < limit; i++ {
        key := host.FullClientKey(clientID, ibctm.ProcessedHeightKey(consensusHeight))
        consensusStateHeightBytes, _ := abortTransactionUnless(chain.QueryStateAtHeight(key, chain.LastHeader.Header.Height, false))

        if consensusStateHeightBytes != nil {
            proofHeight := abortTransactionUnless(clienttypes.ParseHeight(string(consensusStateHeightBytes)))
            return proofHeight, consensusHeight
        }
        consensusHeight = consensusHeight.Increment()
    }

    return nil, nil
}

多跳证明验证步骤

以下概述了多跳 IBC 消息特有的一般证明验证步骤。
  1. 将 multihop 证明字节解包为共识状态、连接状态以及通道/承诺证明数据。
  2. 检查接收端的对手方客户端处于激活状态,且客户端高度大于或等于证明高度。
  3. 遍历连接状态,确定该通道路径的最大 delayPeriod,并验证接收链上的对手方共识状态满足延迟要求。
  4. 遍历连接状态证明,验证每个 connectionEnd 都处于 OPEN 状态,并检查连接 ID 是否与通道的 connectionHops 匹配。
  5. 验证中间状态证明。从 Chain[1] 上给定 proofHeight 的已知 ConsensusState[0] 开始,证明前一条链的共识状态和连接状态。
  6. 验证每个共识状态证明键中的客户端 ID,与前一个连接状态证明中的 ConnectionEnd 里的客户端 ID 相匹配。
  7. 重复第 5 步,证明 ConsensusState[i] 和 Conn[i,i-1],其中 i 是证明索引,从 Chain[2] 上的共识状态开始。ConsensusState[1] 在 Chain[0] 上已知。请注意,链的索引从执行(验证)链开始,而证明则按相反方向索引,以匹配 connectionHops 的顺序。
    • 验证 ParseClientID(ConsensusProofs[i].Key) == ConnectionEnd.ClientID
    • ConsensusProofs[i].Proof.VerifyMembership(ConsensusState.GetRoot(), ConsensusProofs[i].Key, ConsensusProofs[i].Value)
    • ConnectionProofs[i].Proof.VerifyMembership(ConsensusState.GetRoot(), ConnectionProofs[i].Key, ConnectionProofs[i].Value)
    • ConsensusState = ConsensusProofs[i].Value
    • i++
  8. 最后,在 Chain[1] 上的 ConsensusState[N-2](发送链共识状态)中证明预期的通道或数据包承诺。
更多细节请参见 ICS4。

多跳证明验证伪代码

N 条链 C[N] --> C[i] --> C[0] 之间通道的证明生成伪代码
// Parse a client or connection ID from the connection proof key and return it.
func parseID(prefixedKey *PrefixedKey) string {
    keyPath := prefixedKey.KeyPath
    abortTransactionUnless(len(keyPath) >= 2)
    parts := strings.Split(keyPath[1], "/")
    abortTransactionUnless(len(parts) >= 2)
    return parts[1]
}

func parseClientID(prefixedKey *PrefixedKey) string {
    return parseID(prefixedKey)
}

func parseConnectionID(prefixedKey *PrefixedKey) string {
    return parseID(prefixedKey)
}

// VerifyMultihopMembership verifies a multihop membership proof.
// Inputs: consensusState - The consensusState for chain[N-1], which is known on the receiving chain (chain[N]).
//         connectionHops - The expected connectionHops for the channel from the receiving chain to the sending chain.
//         proof          - The serialized multihop proof data.
//         prefix         - Merkleprefix to be combined with key to generate Merklepath for the key/value proof verification.
//         key            - The key to prove in the indexed consensus state.
//         value          - The value to prove in the indexed consensus state.
func VerifyMultihopMembership(
    consensusState exported.ConsensusState,
    connectionHops []string,
    proof MultihopProof,
    prefix exported.Prefix,
    key string,
    value []byte,
) {
    // deserialize proof bytes into multihop proofs
    proofs := abortTransactionUnless(Unmarshal(proof))
    abortTransactionUnless(len(proofs.ConsensusProofs) >= 1)
    abortTransactionUnless(len(proofs.ConnectionProofs) == len(proofs.ConsensusProofs))

    // verify connection hop ordering and connections are in OPEN state
    abortTransactionUnless(VerifyConnectionHops(proofs.ConnectionProofs, connectionHops))

    // verify intermediate consensus and connection states from receiver --> sender
    abortTransactionUnless(VerifyConsensusAndConnectionStates(consensusState, proofs.ConsensusProofs, proofs.ConnectionProofs))

    // verify a key/value proof on source chain's consensus state.
    abortTransactionUnless(VerifyKeyMembership(consensusState, proofs, prefix, key, value))
}

// VerifyMultihopNonMembership verifies a multihop non-membership proof.
// Inputs: consensusState - The consensusState for chain[1], which is known on the receiving chain (chain[0]).
//         connectionHops - The expected connectionHops for the channel from the receiving chain to the sending chain.
//         proof          - The serialized multihop proof data.
//         prefix         - Merkleprefix to be combined with key to generate Merklepath for the key/value proof verification.
//         key            - The key to prove absent in the indexed consensus state
func VerifyMultihopNonMembership(
    consensusState exported.ConsensusState,
    connectionHops []string,
    proof MultihopProof,
    prefix exported.Prefix,
    key string,
) {
    // deserialize proof bytes into multihop proofs
    proofs := abortTransactionUnless(Unmarshal(proof))
    abortTransactionUnless(len(proofs.ConsensusProofs) >= 1)
    abortTransactionUnless(len(proofs.ConnectionProofs) == len(proofs.ConsensusProofs))

    // verify connection hop ordering and connections are in OPEN state
    abortTransactionUnless(VerifyConnectionHops(proofs.ConnectionProofs, connectionHops))

    // verify intermediate consensus and connection states from receiver --> sender
    abortTransactionUnless(VerifyIntermediateStateProofs(consensusState, proofs.ConsensusProofs, proofs.ConnectionProofs))

    // verify a key/value proof on source chain's consensus state.
    abortTransactionUnless(VerifyKeyNonMembership(consensusState, proofs, prefix, key))
}

// VerifyConnectionHops checks that each connection in the multihop proof is OPEN and matches the connections in connectionHops.
func VerifyConnectionHops(
    connectionProofs []*ProofData,
    connectionHops []string,
) {
    abortTransactionUnless(len(connectionProofs) == len(connectionHops)-1)

    // check all connections are in OPEN state and that the connection IDs match and are in the right order
    for i, connData := range connectionProofs {
        connectionEnd := abortTransactionUnless(Unmarshal(connData.Value))

        // Verify the rest of the connectionHops (first hop already verified)
        // 1. check the connectionHop values match the proofs and are in the same order.
        connectionID := parseConnectionID(connData.Key)
        abortTransactionUnless(connectionID == connectionHops[i+1])

        // 2. check that the connectionEnd's are in the OPEN state.
        abortTransactionUnless(connectionEnd.GetState() == int32(connectiontypes.OPEN))
    }
}

// VerifyIntermediateStateProofs verifies the state of each intermediate consensus and connection state
// starting from the receiving chain and finally proving the sending chain consensus and connection state.
func VerifyIntermediateStateProofs(
    consensusState exported.ConsensusState,
    consensusProofs []*ProofData,
    connectionProofs []*ProofData,
) {
    // iterate through proofs to prove from executing chain (receiver) to counterparty chain (sender)
    var connection ConnectionEnd
    for i := 0; i < len(consensusProofs); i++ {
        consensusProof := abortTransactionUnless(Unmarshal(consensusProofs[i].Proof))
        connectionProof := abortTransactionUnless(Unmarshal(connectionProofs[i].Proof))

        // convert to tendermint consensus state
        cs := abortTransactionUnless(consensusState.(*tmclient.ConsensusState))

        // the client id in the consensusState key path should match the clientID for the next connectionEnd
        expectedClientID := parseClientIDFromKey(consensusProof.PrefixedKey.KeyPath)

        abortTransactionUnless(VerifyClientID(clientID, consensusProofs[i].Key))

        // prove the consensus state of chain[i] on chain[i-1]

        abortTransactionUnless(consensusProof.VerifyMembership(
            commitmenttypes.GetSDKSpecs(),
            cs.GetRoot(),
            *consensusProof.Key,
            consensusProof.Value,
        ))

        // prove the connection state of chain[i] on chain[i-1]
        abortTransactionUnless(connectionProof.VerifyMembership(
            commitmenttypes.GetSDKSpecs(),
            cs.GetRoot(),
            *connectionProof.Key,
            connectionProof.Value,
        ))

        // verify that client id in the consensus state path matches the clientID in the connection end
        abortTransactionUnless(Unmarshal(connectionProof.Value, &connection))
        abortTransactionUnless(connection.ClientId == expectedClientID)

        // update the consensusState to prove the next consensus/connection states
        abortTransactionUnless(UnmarshalInterface(consensusProof.Value, &consensusState))
    }
}

// VerifyKeyMembership verifies a key in the indexed chain consensus state.
func VerifyKeyMembership(
    consensusState exported.ConsensusState,
    proofs *MultihopProof,
    prefix exported.Prefix,
    key string,
    value []byte,
) {
    // create prefixed key for proof verification
    prefixedKey := abortTransactionUnless(commitmenttypes.ApplyPrefix(prefix, commitmenttypes.NewMerklePath(key)))

    // reassign consensus state for the final consensus proof if needed
    if len(proofs) > 0 {
        index := uint32(len(proofs.ConsensusProofs)) - 1
        consensusState = abortTransactionUnless(UnmarshalInterface(proofs.ConsensusProofs[index].Value))
    }

    // assign the key proof to verify on the source chain
    keyProof := abortTransactionUnless(Unmarshal(proofs.KeyProof.Proof))

    abortTransactionUnless(keyProof.VerifyMembership(
        commitmenttypes.GetSDKSpecs(),
        consensusState.GetRoot(),
        prefixedKey,
        value,
    ))

}

// VerifyKeyNonMembership verifies a key in the indexed chain consensus state.
func VerifyKeyNonMembership(
    consensusState exported.ConsensusState,
    proofs *MsgMultihopProof,
    prefix exported.Prefix,
    key string,
) {
    // create prefixed key for proof verification
    prefixedKey := abortTransactionUnless(commitmenttypes.ApplyPrefix(prefix, commitmenttypes.NewMerklePath(key)))

    // reassign consensus state for the final consensus proof if needed
     if len(proofs) > 0 {
        index := uint32(len(proofs.ConsensusProofs)) - 1
        consensusState = abortTransactionUnless(UnmarshalInterface(proofs.ConsensusProofs[index].Value))
     }

    // assign the key proof to verify on the source chain
    keyProof := abortTransactionUnless(Unmarshal(proofs.KeyProof.Proof))

    abortTransactionUnless(keyProof.VerifyNonMembership(
        commitmenttypes.GetSDKSpecs(),
        consensusState.GetRoot(),
        prefixedKey,
    ))
}

路径伪造防护

从单个网络的视角看,连接 ID 列表描述了一条由既有连接组成、通往接收链的不可伪造路径。这通过对连接 ID 进行原子递增来保证。 我们必须验证一项证明,确认每一跳的连接 ID 与提供给验证器的已证明连接状态相匹配。此外,还必须将该连接状态与该跳的共识状态关联起来。本质上,我们是在证明该通道的连接路径。

向后兼容性

现有的 IBC 消息发送不应受到影响。为了识别多跳数据包并应用正确的证明验证逻辑,需要对验证逻辑进行少量修改。多跳数据包可以通过 connectionHops 字段中存在多个连接 ID 来轻松识别。

向前兼容性

如果为当前 github 链注册表中的数据开发出一个去中心化的链名称服务,那么调用应用或许只需指定唯一的链名称,而不必显式提供一组 connectionHops。该链名称服务将维护链名称到通道路径的映射。名称服务还可以将路由表更新推送给已订阅的链。由于路由表更新的次数应少于数据包,这样应能减少写入次数。

示例实现

即将推出。

其他实现

即将推出。

历史

2022 年 12 月 16 日 - 对通道规范和证明验证的修订 2022 年 11 月 11 日 - 初始草案

版权

此处所有内容均根据 Apache 2.0 许可证授权。

Synopsis

This document describes a standard for multi-hop IBC channels. Multi-hop channels specify a way to route messages across a path of IBC enabled blockchains utilizing multiple pre-existing IBC connections.

Motivation

The current IBC protocol defines messaging in a point-to-point paradigm which allows message passing between two directly connected IBC chains, but as more IBC enabled chains come into existence there becomes a need to relay IBC packets across chains because IBC connections may not exist between the two chains wishing to exchange messages. IBC connections may not exist for a variety of reasons which could include economic inviability since connections require client state to be continuously exchanged between connection ends which carries a cost.

Definitions

Associated definitions are as defined in referenced prior standards (where the functions are defined), where appropriate. Connection is as defined in ICS 3. Channel is as defined in ICS 4. Channel Path is defined as the path of connection IDs along which a channel is defined. Connection Hop is defined as the connection ID of the connection between two chains along a channel path.

Desired Properties

  • IBC channel handshake and message packets should be able to utilize pre-existing connections to form a logical proof chain to relay messages between unconnected chains.
  • Relaying for a multi-hop IBC channel should NOT require writing additional channel, packet, or timeout state to intermediate hops.
  • The design should strive to minimize the number of required client updates to generate and query multi-hop proofs.
  • Minimal additional required state and changes to core and app IBC specs.
  • Retain desired properties of connection, channel and packet definitions.
  • Retain backwards compatibility for messaging over a single connection hop.

Technical Specification

The bulk of the spec describes multi-hop proof generation and verification. IBC connections remain unchanged. Additionally, channel handshake and packet message types as well as general round trip messaging semantics and flow will remain the same. There is additional work on the verifier side on the receiving chain as well as the relayers who need to query for proofs. Messages passed over multiple hops require proof of the connection path from source chain to receiving chain as well as the packet commitment on the source chain. The connection path is proven by verifying the connection state and consensus state of each connection in the path to the receiving chain. On a high level, this can be thought of as a chained proof over a channel path which enables the receiving chain to prove a key/value on the source chain by iteratively proving each connection and consensus state in the channel path starting with the consensus state associated with the final client on the receiving chain. Each subsequent consensus state and connection is proven until the source chain’s consensus state is proven which can then be used to prove the desired key/value on the source chain.

Assumptions

This multi-hop spec assumes that all proof specs for membership verification for each chain are equal.

Channel Handshake and Packet Messages

For both channel handshake and packet messages, additional connection hops are defined in the pre-existing connectionHops field. The connections along the channel path must exist in the OPEN state to guarantee delivery to the correct recipient. See Path Forgery Protection for more information. See ICS 4 for multi-hop related spec changes. Multi-hop does not change existing spec behavior for channel handshakes, packet delivery, and timeout handling. However, multi-hop channels require special handling for frozen clients (see chanCloseFrozen). In terms of connection topology, a user would be able to determine a viable channel path from sender -> receiver using information from the chain registry. They can also independently verify this information via network queries.

Multihop Relaying

Relayers deliver channel handshake and IBC packets as they currently do except that they are required to provide proof of the channel path. Relayers scan packet events for the connectionHops field and determine if the packet is multi-hop by checking the number of hops in the field. If the number of hops is greater than one then the packet is a multi-hop packet and will need extra proof data. For each multi-hop channel (detailed proof logic below):
  1. Scan source chain for IBC messages to relay.
  2. Read the connectionHops field in from the scanned message to determine the channel path.
  3. Using connection endpoints via chain registry configuration, query for required multi-hop proof heights and update client states along the channel path as necessary (see pseudocode implementation).
  4. Query proof of packet or handshake message commitments on source chain at the proof height used in step 3.
  5. Query for proof of connection, and consensus state for each intermediate connection in the channel path using proof heights determined in step 3.
  6. Submit proofs and data to RPC endpoint on receiving chain.
Relayers are connection topology aware with configurations sourced from the chain registry.

Proof Generation & Verification

proof_generation.png Graphical depiction of proof generation. relayer_calc_update_heights.png During the first phase of querying a multi-hop proof the relayer searches for the minimum consensus height that can prove the previous chain state and is also provable by the next chain in the channel path. relayer_query_proof_and_submit.png In the second phase of querying a multi-hop proof, the relayer queries proofs of the client and connection states as well as the key/value on the source chain at the heights determined in the first phase. proof_query_algorithm.png Multi-hop proof query algorithm. proof_verification.png Multi-hop proof verification logic. Proof generation pseudocode proof generation for a channel path with N chains: C[0] --> C[i] --> C[N]

// Proof generation helper functions
//
// Note: 'Chain' is assumed to contain information about the next chain in the channel path
//
// GetClientID returns the clientID for the next chain in the channel path
func (Chain) GetClientID() (clientID string)
// GetConnectionID returns the connectionID corresponding to the next chain in the channel path
func (Chain) GetConnectionID() (connectionID string)
// GetClientStateHeight returns the client state height for the clientState corresponding to
// the next chain in the channel path
func (Chain) GetClientStateHeight() exported.Height
// QueryStateAtHeight returns the value and proof of a key at the given height along with the height at
// which the proof will succeed.
func (Chain) QueryStateAtHeight(key string, height int64) (value []byte, proof []byte, height exported.Height)
// UpdateClient updates the client state corresponding to the next chain in the channel path
func (*Chain) UpdateClient()

// ProofHeights contains multi-hop proof query height data.
type ProofHeights struct {
    proofHeight     exported.Height // query the proof at this height
    consensusHeight exported.Height // the proof is for the consensusState at this height
}

// ProofData is a generic proof struct.
type ProofData struct {
    Key   *MerklePath
    Value []byte
    Proof []byte
}

// MultihopProof defines a set of proofs to verify a multihop message.
// Consensus and Connection proofs are ordered from receiving to sending chain but do not include
// the chain[1] consensus/connection state on chain[0] since it is already known on the receiving chain.
type MultihopProof struct {
    KeyProof *ProofData            // the key/value proof on the on chain[KeyProofIndex] in the channel path
    ConsensusProofs []*ProofData   // array of consensus proofs starting with proof of consensusState of chain[1] on chain[2]
    ConnectionProofs []*ProofData  // array of connection proofs starting with proof of conn[1,2] on chain[2]
}

// QueryMultihopProof generates proof of a key/value at the proofHeight on indexed chain (chain0).
// Chains are provided in order from the sending (source) chain to the receiving (verifying) chain.
func QueryMultihopProof(
    chains []*Chain,
    key string,
    keyHeight exported.Height,
    includeKeyValue bool,
) (
    multihopProof MultihopProof,
    multihopProofHeight exported.Height,
) {

    abortTransactionUnless(len(chains) > 1)

    // calculate proof heights along channel path
    proofHeights := make([]*ProofHeights, len(chains)-1)
    abortTransactionUnless(calcProofHeights(chains, 1, keyHeight, proofHeights))

    // the consensus state height of the proving chain's counterparty
    // this is where multi-hop proof verification begins
    multihopProofHeight = abortTransactionUnless(proofHeights[len(proofHeights)-1].consensusHeight.Decrement())

    // the key/value proof height is the height of the consensusState on the source chain
    keyHeight = abortTransactionUnless(proofHeights[0].consensusHeight.Decrement())

    var value []byte
    bytes, keyProof := chains[0].QueryStateAtHeight(key, keyHeight)

    if includeKeyValue {
        value = bytes
    }

    // assign the key/value proof
    multihopProof.KeyProof = &ProofData{
        Key:   nil,    // key to prove constructed during verification
        Value: value,  // proven values are constructed during verification (except for frozen client proofs)
        Proof: keyProof,
    }

    // query proofs of consensus/connection states on intermediate chains
    multihopProof.ConsensusProofs = make([]*ProofData, len(chains)-2)
    multihopProof.ConnectionProofs = make([]*ProofData, len(chains)-2)
    multihopProof.ConsensusProofs, multihopProof.ConnectionProofs = abortTransactionUnless(
        queryIntermediateProofs(
            chains,
            len(chains)-2,
            proofHeights,
            multihopProof.ConsensusProofs,
            multihopProof.ConnectionProofs)
        )

    return
}

// calcProofHeights calculates the optimal proof heights to generate a multi-hop proof
// along the channel path and performs client updates as needed.
func calcProofHeights(
    chains []*Chain,
    chainIdx int,
    consensusHeight exported.Height,
    proofHeights []*ProofHeights,
) {
    var height ProofHeights
    chain := chains[chainIdx]

    // find minimum consensus height provable on the next chain
    // i.e. proofHeight is the minimum height at which the consensusState with
    // height=consensusHeight can be proved on the chain (aka processedHeight)
    height.proofHeight, height.consensusHeight = abortTransactionUnless(queryMinimumConsensusHeight(chain, consensusHeight, nil))

    // if no suitable consensusHeight then update client and use latest chain height/client height
    //
    // TODO: It could be more efficient to update the client with the missing block height
    // rather than the latest block height since it would be less likely to need client updates
    // on subsequent chains.
    if height.proofHeight == nil {
        abortTransactionUnless(chain.UpdateClient())
        height.proofHeight = chain.GetLatestHeight()
        height.consensusHeight = chain.GetClientStateHeight(chains[chainIdx+1])
    }

    // stop on the next to last chain
    if chainIdx == len(chains)-2 {
        proofHeights[chainIdx-1] = &height
        return
    }

    // use the proofHeight as the next consensus height
    abortTransactionUnless(calcProofHeights(chains, chainIdx+1, height.proofHeight, proofHeights))

    proofHeights[chainIdx-1] = &height
    return
}

// queryIntermediateProofs recursively queries intermediate chains in a multi-hop channel path for consensus state
// and connection proofs. It stops at the second to last path since the consensus and connection state on the
// final hop is already known on the destination.
func queryIntermediateProofs(
    chains []*Chain,
    proofIdx int,
    proofHeights []*ProofHeights,
    consensusProofs []*ProofData,
    connectionProofs []*ProofData,
) {
     // no need to query proofs on final chain since the clientState is already known
    if proofIdx < 0 {
        return
    }

    chain := chains[proofIdx]
    ph := proofHeights[proofIdx]

    // query proof of the consensusState
    proof := abortTransactionUnless(queryConsensusStateProof(chain, ph.proofHeight, ph.consensusHeight))
    consensusProofs[len(p.Paths)-proofIdx-2] = proof

    // query proof of the connectionEnd
    proof = abortTransactionUnless(queryConnectionProof(chain, ph.proofHeight))
    connectionProofs[len(p.Paths)-proofIdx-2] = proof

    // continue querying proofs on the next chain in the path
    queryIntermediateProofs(chains, proofIdx-1, proofHeights, consensusProofs, connectionProofs)
}

// Query a proof for the counterparty consensus state at the specified height on the given chain.
func queryConsensusStateProof(
    chain Chain,
    proofHeight exported.Height,
    consensusHeight exported.Height,
) *ProofData {

    key := host.FullConsensusStateKey(chain.GetClientID(), consensusHeight)
    consensusStateBytes, consensusStateProof := chain.QueryStateAtHeight(key, int64(proofHeight.GetRevisionHeight()))
    merklePath := abortTransactionUnless(chain.GetMerklePath(string(key)))

    return &ProofData{
        Key:   merklePath,
        Value: consensusStateBytes,
        Proof: consensusStateProof,
    }
}

// Query a proof for the connEnd on the given chain at the specified height.
func queryConnectionProof(
    chain Chain,
    proofHeight exported.Height,
) *ProofData {

    key := host.ConnectionKey(chain.GetConnectionID())
    connectionEndBytes, connectionEndProof := chain.QueryStateAtHeight(key, int64(proofHeight.GetRevisionHeight()))
    merklePath := abortTransactionUnless(chain.GetMerklePath(string(key)))

    return &ProofData{
        Key: merklePath,
        Value: connectionEndBytes,
        Proof: connectionEndProof,
    }
}

// queryMinimumConsensusHeight returns the minimum height within the provided
// range at which a valid consensusState exists (processedHeight) and the
// corresponding consensus state height (consensusHeight).
func queryMinimumConsensusHeight(
    chain Chain,
    minConsensusHeight exported.Height,
    limit uint64,
) (
    processedHeight exported.Height,
    consensusHeight exported.Height
) {

    // find the minimum height consensus state
    consensusHeight := minConsensusHeight
    for i := uint64(0); i < limit; i++ {
        key := host.FullClientKey(clientID, ibctm.ProcessedHeightKey(consensusHeight))
        consensusStateHeightBytes, _ := abortTransactionUnless(chain.QueryStateAtHeight(key, chain.LastHeader.Header.Height, false))

        if consensusStateHeightBytes != nil {
            proofHeight := abortTransactionUnless(clienttypes.ParseHeight(string(consensusStateHeightBytes)))
            return proofHeight, consensusHeight
        }
        consensusHeight = consensusHeight.Increment()
    }

    return nil, nil
}

Multi-hop Proof Verification Steps

The following outlines the general proof verification steps specific to a multi-hop IBC message.
  1. Unpack the multihop proof bytes into consensus states, connection states and channel/commitment proof data.
  2. Check the counterparty client on the receiving end is active and the client height is greater than or equal to the proof height.
  3. Iterate through the connections states to determine the maximum delayPeriod for the channel path and verify that the counterparty consensus state on the receiving chain satisfies the delay requirement.
  4. Iterate through connection state proofs and verify each connectionEnd is in the OPEN state and check that the connection ids match the channel connectionHops.
  5. Verify the intermediate state proofs. Starting with known ConsensusState[0] at the given proofHeight on Chain[1] prove the prior chain’s consensus and connection state.
  6. Verify that the client id in each consensus state proof key matches the client id in the ConnectionEnd in the previous connection state proof.
  7. Repeat step 5, proving ConsensusState[i], and Conn[i,i-1] where i is the proof index starting with the consensus state on Chain[2]. ConsensusState[1] is already known on Chain[0]. Note that chains are indexed from executing (verifying) chain to and proofs are indexed in the opposite direction to match the connectionHops ordering.
    • Verify ParseClientID(ConsensusProofs[i].Key) == ConnectionEnd.ClientID
    • ConsensusProofs[i].Proof.VerifyMembership(ConsensusState.GetRoot(), ConsensusProofs[i].Key, ConsensusProofs[i].Value)
    • ConnectionProofs[i].Proof.VerifyMembership(ConsensusState.GetRoot(), ConnectionProofs[i].Key, ConnectionProofs[i].Value)
    • ConsensusState = ConsensusProofs[i].Value
    • i++
  8. Finally, prove the expected channel or packet commitment in ConsensusState[N-2] (sending chain consensus state) on Chain[1]
For more details see ICS4.

Multi-hop Proof Verification Pseudo Code

Pseudocode proof generation for a channel between N chains C[N] --> C[i] --> C[0]
// Parse a client or connection ID from the connection proof key and return it.
func parseID(prefixedKey *PrefixedKey) string {
    keyPath := prefixedKey.KeyPath
    abortTransactionUnless(len(keyPath) >= 2)
    parts := strings.Split(keyPath[1], "/")
    abortTransactionUnless(len(parts) >= 2)
    return parts[1]
}

func parseClientID(prefixedKey *PrefixedKey) string {
    return parseID(prefixedKey)
}

func parseConnectionID(prefixedKey *PrefixedKey) string {
    return parseID(prefixedKey)
}

// VerifyMultihopMembership verifies a multihop membership proof.
// Inputs: consensusState - The consensusState for chain[N-1], which is known on the receiving chain (chain[N]).
//         connectionHops - The expected connectionHops for the channel from the receiving chain to the sending chain.
//         proof          - The serialized multihop proof data.
//         prefix         - Merkleprefix to be combined with key to generate Merklepath for the key/value proof verification.
//         key            - The key to prove in the indexed consensus state.
//         value          - The value to prove in the indexed consensus state.
func VerifyMultihopMembership(
    consensusState exported.ConsensusState,
    connectionHops []string,
    proof MultihopProof,
    prefix exported.Prefix,
    key string,
    value []byte,
) {
    // deserialize proof bytes into multihop proofs
    proofs := abortTransactionUnless(Unmarshal(proof))
    abortTransactionUnless(len(proofs.ConsensusProofs) >= 1)
    abortTransactionUnless(len(proofs.ConnectionProofs) == len(proofs.ConsensusProofs))

    // verify connection hop ordering and connections are in OPEN state
    abortTransactionUnless(VerifyConnectionHops(proofs.ConnectionProofs, connectionHops))

    // verify intermediate consensus and connection states from receiver --> sender
    abortTransactionUnless(VerifyConsensusAndConnectionStates(consensusState, proofs.ConsensusProofs, proofs.ConnectionProofs))

    // verify a key/value proof on source chain's consensus state.
    abortTransactionUnless(VerifyKeyMembership(consensusState, proofs, prefix, key, value))
}

// VerifyMultihopNonMembership verifies a multihop non-membership proof.
// Inputs: consensusState - The consensusState for chain[1], which is known on the receiving chain (chain[0]).
//         connectionHops - The expected connectionHops for the channel from the receiving chain to the sending chain.
//         proof          - The serialized multihop proof data.
//         prefix         - Merkleprefix to be combined with key to generate Merklepath for the key/value proof verification.
//         key            - The key to prove absent in the indexed consensus state
func VerifyMultihopNonMembership(
    consensusState exported.ConsensusState,
    connectionHops []string,
    proof MultihopProof,
    prefix exported.Prefix,
    key string,
) {
    // deserialize proof bytes into multihop proofs
    proofs := abortTransactionUnless(Unmarshal(proof))
    abortTransactionUnless(len(proofs.ConsensusProofs) >= 1)
    abortTransactionUnless(len(proofs.ConnectionProofs) == len(proofs.ConsensusProofs))

    // verify connection hop ordering and connections are in OPEN state
    abortTransactionUnless(VerifyConnectionHops(proofs.ConnectionProofs, connectionHops))

    // verify intermediate consensus and connection states from receiver --> sender
    abortTransactionUnless(VerifyIntermediateStateProofs(consensusState, proofs.ConsensusProofs, proofs.ConnectionProofs))

    // verify a key/value proof on source chain's consensus state.
    abortTransactionUnless(VerifyKeyNonMembership(consensusState, proofs, prefix, key))
}

// VerifyConnectionHops checks that each connection in the multihop proof is OPEN and matches the connections in connectionHops.
func VerifyConnectionHops(
    connectionProofs []*ProofData,
    connectionHops []string,
) {
    abortTransactionUnless(len(connectionProofs) == len(connectionHops)-1)

    // check all connections are in OPEN state and that the connection IDs match and are in the right order
    for i, connData := range connectionProofs {
        connectionEnd := abortTransactionUnless(Unmarshal(connData.Value))

        // Verify the rest of the connectionHops (first hop already verified)
        // 1. check the connectionHop values match the proofs and are in the same order.
        connectionID := parseConnectionID(connData.Key)
        abortTransactionUnless(connectionID == connectionHops[i+1])

        // 2. check that the connectionEnd's are in the OPEN state.
        abortTransactionUnless(connectionEnd.GetState() == int32(connectiontypes.OPEN))
    }
}

// VerifyIntermediateStateProofs verifies the state of each intermediate consensus and connection state
// starting from the receiving chain and finally proving the sending chain consensus and connection state.
func VerifyIntermediateStateProofs(
    consensusState exported.ConsensusState,
    consensusProofs []*ProofData,
    connectionProofs []*ProofData,
) {
    // iterate through proofs to prove from executing chain (receiver) to counterparty chain (sender)
    var connection ConnectionEnd
    for i := 0; i < len(consensusProofs); i++ {
        consensusProof := abortTransactionUnless(Unmarshal(consensusProofs[i].Proof))
        connectionProof := abortTransactionUnless(Unmarshal(connectionProofs[i].Proof))

        // convert to tendermint consensus state
        cs := abortTransactionUnless(consensusState.(*tmclient.ConsensusState))

        // the client id in the consensusState key path should match the clientID for the next connectionEnd
        expectedClientID := parseClientIDFromKey(consensusProof.PrefixedKey.KeyPath)

        abortTransactionUnless(VerifyClientID(clientID, consensusProofs[i].Key))

        // prove the consensus state of chain[i] on chain[i-1]

        abortTransactionUnless(consensusProof.VerifyMembership(
            commitmenttypes.GetSDKSpecs(),
            cs.GetRoot(),
            *consensusProof.Key,
            consensusProof.Value,
        ))

        // prove the connection state of chain[i] on chain[i-1]
        abortTransactionUnless(connectionProof.VerifyMembership(
            commitmenttypes.GetSDKSpecs(),
            cs.GetRoot(),
            *connectionProof.Key,
            connectionProof.Value,
        ))

        // verify that client id in the consensus state path matches the clientID in the connection end
        abortTransactionUnless(Unmarshal(connectionProof.Value, &connection))
        abortTransactionUnless(connection.ClientId == expectedClientID)

        // update the consensusState to prove the next consensus/connection states
        abortTransactionUnless(UnmarshalInterface(consensusProof.Value, &consensusState))
    }
}

// VerifyKeyMembership verifies a key in the indexed chain consensus state.
func VerifyKeyMembership(
    consensusState exported.ConsensusState,
    proofs *MultihopProof,
    prefix exported.Prefix,
    key string,
    value []byte,
) {
    // create prefixed key for proof verification
    prefixedKey := abortTransactionUnless(commitmenttypes.ApplyPrefix(prefix, commitmenttypes.NewMerklePath(key)))

    // reassign consensus state for the final consensus proof if needed
    if len(proofs) > 0 {
        index := uint32(len(proofs.ConsensusProofs)) - 1
        consensusState = abortTransactionUnless(UnmarshalInterface(proofs.ConsensusProofs[index].Value))
    }

    // assign the key proof to verify on the source chain
    keyProof := abortTransactionUnless(Unmarshal(proofs.KeyProof.Proof))

    abortTransactionUnless(keyProof.VerifyMembership(
        commitmenttypes.GetSDKSpecs(),
        consensusState.GetRoot(),
        prefixedKey,
        value,
    ))

}

// VerifyKeyNonMembership verifies a key in the indexed chain consensus state.
func VerifyKeyNonMembership(
    consensusState exported.ConsensusState,
    proofs *MsgMultihopProof,
    prefix exported.Prefix,
    key string,
) {
    // create prefixed key for proof verification
    prefixedKey := abortTransactionUnless(commitmenttypes.ApplyPrefix(prefix, commitmenttypes.NewMerklePath(key)))

    // reassign consensus state for the final consensus proof if needed
     if len(proofs) > 0 {
        index := uint32(len(proofs.ConsensusProofs)) - 1
        consensusState = abortTransactionUnless(UnmarshalInterface(proofs.ConsensusProofs[index].Value))
     }

    // assign the key proof to verify on the source chain
    keyProof := abortTransactionUnless(Unmarshal(proofs.KeyProof.Proof))

    abortTransactionUnless(keyProof.VerifyNonMembership(
        commitmenttypes.GetSDKSpecs(),
        consensusState.GetRoot(),
        prefixedKey,
    ))
}

Path Forgery Protection

From the view of a single network, a list of connection IDs describes an unforgeable path of pre-existing connections to a receiving chain. This is ensured by atomically incrementing connection IDs. We must verify a proof that the connection ID of each hop matches the proven connection state provided to the verifier. Additionally we must link the connection state to the consensus state for that hop as well. We are essentially proving out the connection path of the channel.

Backwards Compatibility

The existing IBC message sending should not be affected. Minor changes to the verification logic would be required to identify a multi-hop packet and apply the proper proof verification logic. Multi-hop packets should be easily identified by the existence of more than one connection ID in the connectionHops field.

Forwards Compatibility

If a decentralized chain name service is developed for the data currently in the chain registry on github, it may be possible for a calling app to specify only a unique chain name rather than an explicit set of connectionHops. The chain name service would maintain a mapping of chain names to channel paths. The name service could push routing table updates to subscribed chains. This should require fewer writes since routing table updates should be fewer than packets.

Example Implementation

Coming soon.

Other Implementations

Coming soon.

History

Dec 16, 2022 - Revisions to channel spec and proof verification Nov 11, 2022 - Initial draft All content herein is licensed under Apache 2.0.