概述

本文档标准规定了在两条独立链上的两个模块之间,通过 IBC 通道传输同质化代币时的数据包结构、状态机处理逻辑以及编码细节。本文给出的状态机逻辑支持在无需许可开启通道的前提下,安全地处理多链面额。该逻辑构成了一个“同质化代币转移桥接模块”,在 IBC 路由模块与宿主状态机中现有的资产跟踪模块之间提供接口。

动机

一组通过 IBC 协议连接的链的用户,可能希望在另一条链上使用某条链发行的资产,例如为了利用额外功能,如兑换或隐私保护,同时保持其与发行链上原始资产的同质性。该应用层标准描述了一种通过 IBC 在链间转移同质化代币的协议,它能够保持资产的同质性、保持资产所有权、限制拜占庭故障的影响,并且不需要任何额外许可。

定义

IBC 处理器接口与 IBC 路由模块接口分别定义于 ICS 25 和 ICS 26。

期望属性

  • 保持同质性(双向锚定)。
  • 保持总供应量(在单一源链和模块上保持恒定或通胀)。
  • 无需许可的代币转移,无需将连接、模块或面额加入白名单。
  • 对称性(所有链都实现相同逻辑,协议内不区分 hub 与 zone)。
  • 故障隔离:防止由于链 B 的拜占庭行为,导致源自链 A 的代币被拜占庭式增发(不过,任何将代币发送到链 `B“ 的用户都可能面临风险)。

技术规范

数据结构

只需要一种数据包类型:FungibleTokenPacketData,用于指定面额、数量、发送账户和接收账户;或者使用 FungibleTokenPacketDataV2,用于指定发送方与接收方之间传输的多个代币,以及一个可选的转发路径,该路径可将代币继续转发到初始接收链之外的位置。支持 v2 的链可以选择对仍处于版本 1 的通道将 v1 数据包转换为 v2。
interface FungibleTokenPacketData {
  denom: string
  amount: uint256
  sender: string
  receiver: string
  memo: string
}

interface FungibleTokenPacketDataV2 {
  tokens: []Token
  sender: string
  receiver: string
  memo: string
  // a struct containing the list of next hops, 
  // determining where the tokens must be forwarded next, 
  // and the memo for the final hop
  forwarding: Forwarding 
}

interface Token {
  denom: Denom 
  amount: uint256
}

interface Denom {
  base: string // base denomination
  trace: []Hop
}

interface Forwarding {
  hops: []Hop
  memo: string
}

interface Hop {
  portId: string
  channelId: string
}
当代币使用 ICS 20 协议跨链发送时,它们会开始累积一份经过哪些通道传输的记录。这些信息会被编码到代币的 trace 字段中。 ICS 20 的代币 trace 由一组 ics20Port 与 ics20Channel 配对表示,即当前链上该笔资金所在的 ICS 20 端口与通道。端口与通道配对表明该笔资金此前是通过哪个通道发送过来的。实现方负责正确解析 IBC trace 信息,并将其编码到最终的链上面额中,从而确保经由不同路径发送的相同基础面额不会被视为同质。 发送链可能充当源区或汇区。当某条链通过某个端口和通道发送代币,而该端口和通道不等于最后一个前缀端口和通道配对时,它充当源区。当代币从源区发送时,目标端口和通道会在代币被接收后追加到 trace 的前部,从而在代币记录中增加一跳。当某条链通过某个端口和通道发送代币,而该端口和通道等于最后一个前缀端口和通道配对时,它充当汇区。当代币从汇区发送时,trace 的第一个元素,也就是最后加入 trace 的端口和通道配对,会在代币被接收后被移除,从而撤销代币记录中的最后一跳。关于这一点,更完整的说明可见 ibc-go 实现。 下列时序图展示了多链代币转移的动态过程。该过程涵盖了一次起点和终点均为同一条链的循环转移步骤,途经链 A、链 B 和链 C。操作顺序如下:A -> B -> C -> A -> C -> B -> A。 v2 数据包中的转发路径会告诉接收链下一步应将代币发送到哪里。该路径必须构造为一个由 portID/channelID 配对组成的列表,其中每个元素都拼接为 portID/channelID。这使用户能够自动在跨链网络中路由代币。一个常见用例是先将代币的 trace 回退到其原始源链,再将其继续发送到最终目标地址。 以下是转移数据包的示例:

// V1 example of transfer packet data
FungibleTokenPacketData {
  denom: "transfer/channel-1/transfer/channel-4/uatom",
  amount: 500,
  sender: cosmosexampleaddr1,
  receiver: cosmosexampleaddr2,
  memo: "exampleMemo",
}

// V2 example of transfer packet data
FungibleTokenPacketDataV2 {
  tokens: [
    Token{
      denom: Denom{
        base: "uatom",
        trace: [
          Hop{
            portId: "transfer",
            channelId: "channel-1",
          }, 
          Hop{
            portId: "transfer",
            channelId: "channel-4",
          }
        ],
      },
      amount: 500,
    },
    Token{
      denom: Denom{
        base: "btc",
        trace: [
          Hop{
            portId: "transfer",
            channelId: "channel-3",
          }
        ],
      },
      amount: 7,
    }
  ],
  sender: cosmosexampleaddr1,
  receiver: cosmosexampleaddr2,
  memo: "",
  forwarding: {
    hops: [
      Hop{portId: "transfer", channelId: "channel-7"},
      Hop{portId: "transfer", channelId: "channel-13"},
    ],  
    memo: "swap: {...}"
  }, // provide hops in order and the memo intended for final hop
}
确认数据类型用于描述转移成功还是失败,以及失败原因(如有)。
type FungibleTokenPacketAcknowledgement = FungibleTokenPacketSuccess | FungibleTokenPacketError;

interface FungibleTokenPacketSuccess {
  // This is binary 0x01 base64 encoded
  result: "AQ=="
}

interface FungibleTokenPacketError {
  error: string
}
请注意,FungibleTokenPacketData 和 FungibleTokenPacketAcknowledgement 在序列化为数据包数据时,都必须采用 JSON 编码(而非 Protobuf 编码)。还需注意,uint256 在转换为 JSON 时会被编码为字符串,但必须是形如 [0-9]+ 的合法十进制数字。 同质化代币转移桥接模块会在状态中跟踪与特定通道关联的托管地址。假定 ModuleState 的字段已在作用域中可用。
interface ModuleState {
  channelEscrowAddresses: Map<Identifier, string>
  channelForwardingAddresses: Map<Identifier, string>
}

存储路径

数据包转发路径

带有非空转发信息、因此需要被转发的 v2 数据包,必须存储在私有存储中,以便在收到被转发数据包的确认或超时时,能够为其写入确认。
function packetForwardPath(portIdentifier: Identifier, channelIdentifier: Identifier, sequence: uint64): Path {
  return "forwardedPackets/ports/{portIdentifier}/channels/{channelIdentifier}/sequences/{sequence}"
}

子协议

本文所述的子协议应实现于一个“同质化代币转移桥接”模块中,该模块能够访问银行模块以及 IBC 路由模块。

端口与通道设置

在模块创建时(可能是在区块链本身初始化时),必须且只能调用一次 setup 函数,以绑定到适当的端口并创建一个由该模块拥有的托管地址。
function setup() {
  capability = routingModule.bindPort("transfer", ModuleCallbacks{
    onChanOpenInit,
    onChanOpenTry,
    onChanOpenAck,
    onChanOpenConfirm,
    onChanCloseInit,
    onChanCloseConfirm,
    onRecvPacket,
    onTimeoutPacket,
    onAcknowledgePacket,
    onTimeoutPacketClose
  })
  claimCapability("port", capability)
}
一旦调用了 setup 函数,不同链上的同质化代币转移模块实例之间便可以通过 IBC 路由模块创建通道。 管理员(在宿主状态机上具有创建连接和通道权限)负责建立与其他状态机的连接,并创建通往其他链上该模块实例(或支持该接口的其他模块)的通道。本规范仅定义数据包处理语义,并且其定义方式使得模块本身无需关心任意时刻可能存在或不存在什么连接或通道。

路由模块回调

通道生命周期管理
当且仅当满足以下条件时,机器 A 和 B 才会接受来自另一台机器上任意模块的新通道:
  • 正在创建的通道是无序通道。
  • 版本字符串是 ics20-1 或 ics20-2。
function onChanOpenInit(
  order: ChannelOrder,
  connectionHops: [Identifier],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  version: string) => (version: string, err: Error) {
  // only unordered channels allowed
  abortTransactionUnless(order === UNORDERED)
  // assert that version is "ics20-1" or "ics20-2" or empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics20-2" || version === "ics20-1" || version === "")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  if version == "" {
    // default to latest supported version
    return "ics20-2", nil
  }
  // If the version is not empty and is among those supported, we return the version
  return version, nil 
}
function onChanOpenTry(
  order: ChannelOrder,
  connectionHops: [Identifier],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) => (version: string, err: Error) {
  // only unordered channels allowed
  abortTransactionUnless(order === UNORDERED)
  // assert that version is "ics20-1" or "ics20-2" 
  abortTransactionUnless(counterpartyVersion === "ics20-1" || counterpartyVersion === "ics20-2")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  // return the same version as counterparty version so long as we support it
  return counterpartyVersion, nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) {
  // port has already been validated
  // assert that counterparty selected version is the same as our version
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  abortTransactionUnless(counterpartyVersion === channel.version)
}
function onChanOpenConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
  // accept channel confirmations, port has already been validated, version has already been validated
}
function onChanCloseInit(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
    // always abort transaction
    abortTransactionUnless(FALSE)
}
function onChanCloseConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
  // no action necessary
}
数据包中继
用通俗的话说,在链 A 和 B 之间:
  • 当作为源区时,桥接模块会在发送链上托管现有的本地资产面额,并在接收链上铸造凭证。
  • 当作为汇区时,桥接模块会在发送链上销毁本地凭证,并在接收链上解除托管本地资产面额。
  • 当数据包超时时,会根据具体情况将本地资产解除托管返还给发送者,或将凭证重新铸造返还给发送者。
  • 确认数据用于处理失败情况,例如无效面额或无效目标账户。相比中止交易,返回失败确认更可取,因为这样更容易让发送链根据失败的性质采取适当行动。
注意:constructOnChainDenom 是一个辅助函数,用于为桥接代币构造链上的本地面额。它必须对 trace 和基础面额进行编码,以确保经由不同路径过来的代币不会被视为同质代币。状态机必须能够取回原始 trace 和面额,以便在为桥接代币构造新的 IBC 路径时按原始形式传递。ibc-go 实现通过创建本地面额 hash(trace+base_denom) 来处理这一点。 sendFungibleTokens 必须由模块中的交易处理器调用,该处理器会根据宿主状态机上的账户所有者执行适当的签名校验。
function sendFungibleTokens(
  tokens: []Token,
  sender: string,
  receiver: string,
  memo: string,
  forwarding: Forwarding,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64, // unix 纳秒时间戳
): uint64 {
  // memo 和 forwarding 不能同时为非空
  abortTransactionUnless(memo != "" && forwarding != nil)
  for token in tokens 
    onChainDenom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // 如果该代币没有以我们通道端的端口和通道标识符作为前缀
    // 那么我们就是作为源区发送
    if !isTracePrefixed(sourcePort, sourceChannel, token) {
      // 确定托管账户
      escrowAccount = channelEscrowAddresses[sourceChannel]
      // 托管源代币(假定余额不足时会失败)
      bank.TransferCoins(sender, escrowAccount, onChainDenom, token.amount)
    } else {
      // 接收方是源链,销毁凭证
      bank.BurnCoins(sender, onChainDenom, token.amount)
    }
  }

  var dataBytes bytes
  channel = provableStore.get(channelPath(sourcePort, sourceChannel))
  // getAppVersion 返回嵌入在通道版本中的 transfer 版本
  // 因为通道版本可能还包含其他应用或中间件版本
  transferVersion = getAppVersion(channel.version)
  if transferVersion == "ics20-1" {
    abortTransactionUnless(len(tokens) == 1)
    token = tokens[0]
    // 如果定义了 forwarding 则中止
    abortTransactionUnless(forwarding == nil)
    // 创建如下形式的 v1 面额:port1/channel1/port2/channel2/port3/channel3/denom
    v1Denom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // v1 数据包数据不支持 forwarding 字段
    data = FungibleTokenPacketData{v1Denom, token.amount, sender, receiver, memo}
    // 将数据包数据以 JSON 编组为字节
    dataBytes = json.marshal(data)
  } else if transferVersion == "ics20-2" {
    // 创建 FungibleTokenPacket 数据
    data = FungibleTokenPacketDataV2{tokens, sender, receiver, memo, forwarding}
    // 将数据包数据以 protobuf 编组为字节
    dataBytes = protobuf.marshal(data)
  } else {
    // 理论上不应到达这里,因为 transfer 版本必须在通道握手期间
    // 协商为 ics20-1 或 ics20-2 之一
    abortTransactionUnless(false)
  }

  // 使用 ICS4 中定义的接口发送数据包
  sequence = handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    dataBytes,
  )

  return sequence
}
onRecvPacket 会在路由模块收到发往该模块的数据包时被调用。 注意:函数 parseICS20V1Denom 是一个辅助函数,它会接收完整的 IBC 面额,并提取所接收代币的基础面额(即源链上的原生面额)以及 trace 信息(如果有)。
function onRecvPacket(packet: Packet) {
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  // getAppVersion 返回嵌入在通道版本中的 transfer 版本
  // 因为通道版本可能还包含其他应用或中间件版本
  transferVersion = getAppVersion(channel.version)
  var tokens []Token
  var sender string
  var receiver string // 本链上接收代币的地址
  var finalReceiver string // forwarding 场景下最终目标地址

  if transferVersion == "ics20-1" {
     FungibleTokenPacketData data = json.unmarshal(packet.data)
     // 将完整面额字符串转换为包含基础面额和 trace 的面额结构
     denom = parseICS20V1Denom(data.denom)
     token = Token{
       denom: denom
       amount: data.amount
     }
     tokens = []Token{token}
     sender = data.sender
     receiver = data.receiver
  } else if transferVersion == "ics20-2" {
    FungibleTokenPacketDataV2 data = protobuf.unmarshal(packet.data)
    tokens = data.tokens
    sender = data.sender

    // 如果我们需要继续向后转发这些代币
    // 则覆盖 receiver,临时发送到
    // 目标接收方对应的通道托管地址
    if len(data.forwarding.hops) > 0 {
      // memo 必须为空
      abortTransactionUnless(data.memo == "")
      if channelForwardingAddress[packet.destChannel] == "" {
        channelForwardingAddress[packet.destChannel] = newAddress()
      }
      receiver = channelForwardingAddresses[packet.destChannel]
      finalReceiver = data.receiver
    } else {
      receiver = data.receiver
    }
  } else {
    // 理论上不应到达这里,因为 transfer 版本必须在通道握手期间
    // 协商为 ics20-1 或 ics20-2 之一
    abortTransactionUnless(false)
  }

  assert(sender !== "")
  assert(receiver !== "")
    
  // 构造默认的成功确认
  FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, null}

  receivedTokens = []Token
  for token in tokens {
    assert(token.denom !== nil)
    assert(token.amount > 0)
    
    var onChainTrace []Hop
    // 如果数据包带有发送链的前缀,则我们是源
    // 如果发送者发送的代币带有其通道端的
    // 端口和通道标识符前缀,那么我们接收到的是
    // 之前发送给该发送者的代币,因此我们是以源区身份
    // 接收这些代币
    if isTracePrefixed(packet.sourcePort, packet.sourceChannel, token) {
      // 由于我们是接收回源,因此从 trace 中移除前缀
      onChainTrace = token.trace[1:]
      onChainDenom = constructOnChainDenom(onChainTrace, token.denom.base)
      // 接收方是源链:解除代币托管
      // 确定托管账户
      escrowAccount = channelEscrowAddresses[packet.destChannel]
      // 将解除托管的代币转给接收方(假定余额不足时会失败)
      err = bank.TransferCoins(escrowAccount, receiver, onChainDenom, token.amount)
      if (err != nil) {
        ack = FungibleTokenPacketAcknowledgement{false, "transfer coins failed"}
        // 首次出错即跳出 for 循环
        break
      }
    } else {
      // 由于我们是在新的汇区接收,因此在 trace 前添加前缀
      prefixTrace = Hop{portId: packet.destPort, channelId: packet.destChannel}
      onChainTrace = append([]Hop{prefixTrace}, token.denom.trace...)
      onChainDenom = constructOnChainDenom(onChainTrace, token.denom.base)
      // 发送方是源,向接收方铸造凭证(假定余额不足时会失败)
      err = bank.MintCoins(receiver, onChainDenom, token.amount)
      if (err !== nil) {
        ack = FungibleTokenPacketAcknowledgement{false, "mint coins failed"}
        // 首次出错即跳出 for 循环
        break
      }
    }

    // 将接收到的代币加入接收代币列表
    recvToken = Token{
      denom: Denom{base: token.denom.base, trace: onChainTrace},
      amount: token.amount,
    }
    receivedTokens = append(receivedTokens, recvToken)
  }

  // 如果是错误确认则立即返回,不再继续转发
  if !ack.Success() {
    return ack
  }

  // 如果确认成功且设置了 forwarding 路径
  // 则开始转发
  if len(forwarding.hops) > 0 {
    // 检查下一条通道是否支持代币转发
    channel = provableStore.get(channelPath(forwarding.hops[0].portId, forwarding.hops[0].channelId))
    if channel.version != "ics20-2" && len(forwarding.hops) > 1 {
      ack = FungibleTokenPacketAcknowledgement(false, "next hop in path cannot support forwarding onward")
      return ack
    }
    memo = ""
    nextForwarding = Forwarding{
      hops: forwarding.hops[1:]
      memo: forwarding.memo
    }
    if len(forwarding.hops) == 1 {
      // 我们已经在最后一跳,可以设置 memo 并清除
      // 下一步 forwarding
      memo = forwarding.memo
      nextForwarding = nil
    }
    // 将上面接收到的代币发送到 forwarding 路径中的
    // 下一个端口和通道
    // 并将 forwarding 去掉第一个元素
    packetSequence = sendFungibleTokens(
      receivedTokens,
      receiver, // 下一个数据包的发送者
      finalReceiver, // 下一个数据包的接收者
      memo,
      nextForwarding,
      forwarding.hops[0].portId,
      forwarding.hops[0].channelId,
      Height{},
      currentTime() + DefaultHopTimeoutPeriod,
    )
    // 存储数据包以便后续发送确认
    privateStore.set(packetForwardPath(forwarding.hops[0].portId, forwarding.hops[0].channelId, packetSequence), packet)
    // 使用异步确认,直到收到更下游的成功确认。
    return nil
  }

  return ack
}
onAcknowledgePacket 会在由该模块发送的数据包被确认时由路由模块调用。
function onAcknowledgePacket(
  packet: Packet,
  acknowledgement: bytes) {
  // 如果传输失败,则将代币退款
  // 给发送者账户。对于转发数据包发送出的
  // 数据包,发送者是该转发数据包目标通道的
  // forwarding 地址。
  if !(acknowledgement.success) {
    refundTokens(packet)
  }

  // 检查已发送的数据包是否来自先前转发的数据包
  prevPacket = privateStore.get(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))

  if prevPacket != nil {
    if acknowledgement.success {
      FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, "forwarded packet succeeded"}
      handler.writeAcknowledgement(
        prevPacket,
        ack,
      )
    } else {
      // 转发的数据包失败,因此资金已退回到 forwarding 地址。
      // 在将错误确认继续传播回原始发送链之前,
      // 我们必须回滚在本链成功接收这些代币时产生的变更
      revertInFlightChanges(packet, prevPacket)
      // 写入错误确认
      FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{false, "forwarded packet failed"}
      handler.writeAcknowledgement(
        prevPacket,
        ack,
      )
    }

    // 删除触发发送此数据包的那个已转发数据包
    privateStore.delete(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))
  }
}
onTimeoutPacket 在由本模块发送的数据包发生超时(因此不会被目标链接收)时,由路由模块调用。
function onTimeoutPacket(packet: Packet) {
  // the packet timed-out, so refund the tokens
  // to the sender account. In case of a packet sent for a
  // forwarded packet, the sender is the forwarding
  // address for the destination channel of the forwarded packet.
  refundTokens(packet)

  // check if the packet sent is from a previously forwarded packet
  prevPacket = privateStore.get(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))

  if prevPacket != nil {
    // the forwarded packet has failed, thus the funds have been refunded to the forwarding address.
    // we must revert the changes that came from successfully receiving the tokens on our chain
    // before propogating the error acknowledgement back to original sender chain
    revertInFlightChanges(packet, prevPacket)
    // write error acknowledgement
    FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{false, "forwarded packet timed out"}
    handler.writeAcknowledgement(
      prevPacket,
      ack,
    )

    // delete the forwarded packet that triggered sending this packet
    privateStore.delete(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))
  }
}
给定三条链,以及一笔通过链 B 从链 A 转移到链 C 的转账,下面的图总结了协议关于中间链上代币处理的核心逻辑,分别对应成功情形(即链 C 收到代币)和失败情形(即链 C 无法收到代币并写入错误确认): 转发成功情形 转发失败情形
辅助函数
// helper function that returns true if the first element of the trace of the 
// token is matches the provided portId and channelId; otherwise it returns false
function isTracePrefixed(portId: string, channelId: string, token: Token) boolean {
  trace = token.trace[0]
  return trace.portId == portId && trace.channelId == channelId
}
refundTokens 会在失败时由 onAcknowledgePacket 调用,也会由 onTimeoutPacket 调用,以将托管的代币退还给原始发送方。
function refundTokens(packet: Packet) {
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  // getAppVersion returns the transfer version that is embedded in the channel version
  // as the channel version may contain additional app or middleware version(s)
  transferVersion = getAppVersion(channel.version)
  if transferVersion == "ics20-1" {
     FungibleTokenPacketData data = json.unmarshal(packet.data)
     // convert full denom string to denom struct with base denom and trace
     denom = parseICS20V1Denom(data.denom)
     token = Token{
       denom: denom
       amount: data.amount
     }
     tokens = []Token{token}
  } else if transferVersion == "ics20-2" {
    FungibleTokenPacketDataV2 data = protobuf.unmarshal(packet.data)
    tokens = data.tokens
  } else {
    // should never be reached as transfer version must be negotiated to be either
    // ics20-1 or ics20-2 during channel handshake
    abortTransactionUnless(false)
  }

  for token in tokens {
    onChainDenom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // Since this is refunding an outgoing packet, we can check if the tokens 
    // were originally from the receiver by checking if the tokens were prefixed
    // by our channel end's identifiers.
    if !isTracePrefixed(packet.sourcePort, packet.sourceChannel, token) {
      // sender was source chain, unescrow tokens back to sender
      escrowAccount = channelEscrowAddresses[packet.sourceChannel]
      bank.TransferCoins(escrowAccount, data.sender, onChainDenom, token.amount)
    } else {
      // receiver was source chain, mint vouchers back to sender
      bank.MintCoins(data.sender, onChainDenom, token.amount)
    }
  }
}
// revertInFlightChanges reverts the receive packet
// that occurs in the middle chains during a packet forwarding
// If an error occurs further down the line, the state changes
// on this chain must be reverted before sending back the error acknowledgement
// to ensure atomic packet forwarding
function revertInFlightChanges(sentPacket: Packet, receivedPacket: Packet) {
  forwardingAddress = channelForwardingAddress[receivedPacket.destChannel]
  reverseEscrow = channelEscrowAddresses[receivedPacket.destChannel]

  // the token on our chain is the token in the sentPacket
  for token in sentPacket.tokens {
    // we are checking if the tokens that were sent out by our chain in the 
    // sentPacket were source tokens with respect to the original receivedPacket.
    // If the tokens in sentPacket were prefixed by our channel end's port and channel
    // identifiers, then it was a minted voucher and we need to burn it.
    // Otherwise, it was an original token from our chain and we must give the tokens
    // back to the escrow account.
    if !isTracePrefixed(receivedPacket.destinationPort, receivedPacket.destinationChannel, token) {
      // receive sent tokens from the received escrow account to the forwarding account
      // so we must send the tokens back from the forwarding account to the received escrow account
      bank.TransferCoins(forwardingAddress, reverseEscrow, token.denom, token.amount)
    } else {
      // receive minted vouchers and sent to the forwarding account
      // so we must burn the vouchers from the forwarding account
      bank.BurnCoins(forwardingAddress, token.denom, token.amount)
    }
  }
}
function onTimeoutPacketClose(packet: Packet) {
  // can't happen, only unordered channels allowed
}

使用 Memo 字段

注意:由于本规范的早期版本不包含 memo 字段,实现必须确保新的数据包数据仍然兼容那些期望旧数据包数据的链。传统实现必须能够将带有空字符串 memo 的新数据包数据反序列化到传统的 FungibleTokenPacketData 结构体中。同样地,支持 memo 的实现必须能够将传统数据包数据反序列化到当前结构体中,并将 memo 字段设为空字符串。 memo 字段本身不会在 transfer 内部使用,但它可以供外部链下用户(例如交易所)使用,也可以供封装 transfer 的中间件使用,后者可基于传入的 memo 解析并执行自定义逻辑。如果 memo 预期由更高层中间件解析和解释,则建议这些中间件为自己写入 memo 字符串的内容使用命名空间,以避免彼此覆盖。链应确保对整个数据包数据设置某种长度限制,以避免数据包成为 DoS 攻击向量。不过,这些限制不需要由协议统一定义。如果接收方因长度限制而无法接收数据包,则会导致发送方一侧发生超时。 对于打算由更高层中间件读取并用于自定义执行的 memo,其结构必须使不同中间件能够读取其中与自己相关的数据,同时不干扰为其他中间件准备的数据。 因此,对于任何打算由状态机解释的 memo,建议将 memo 设计为一个 JSON 对象,并由每个中间件预留一个自己可以读取的键,以获取相关数据。这样就可以构造 memo,使多个中间件能够在互不干扰的情况下读取其中的信息。 示例:
{
  "wasm": {
    "address": "contractAddress",
    "arguments": "marshalledArguments",
  },
  "callback": "contractAddress",
  "router": "routerArgs",
}
这里,"wasm"、"callback" 和 "router" 字段分别面向不同的中间件,这些中间件将各自仅读取对应字段以执行其逻辑。这样多个模块都可以从 memo 中读取数据。中间件应注意预留唯一的键,避免意外读取本应属于其他模块的数据。这个问题可以通过某种链下注册表来避免,用于登记 JSON 对象中已经占用的键。

原理说明

正确性
该实现同时保持了可替代性与供应量。 可替代性:如果代币已发送到对手链,则可以在源链上按相同面额和数量赎回。 供应量:将供应量重新定义为已解锁代币。所有发送-接收对的净和为零。源链可以改变供应量。
多链说明
本规范并不直接处理“菱形问题”:用户将源自链 A 的代币发送到链 B,再发送到链 D,并希望通过 D -> C -> A 的路径返还该代币。由于供应量被跟踪为由链 B 持有(且面额将是 "{portOnD}/{channelOnD}/{portOnB}/{channelOnB}/denom"),链 C 无法充当中间方。目前尚不清楚这一情形是否应在协议内解决,也许只要求按原始赎回路径返回就足够了(如果两条路径上经常存在流动性且都有一定盈余,那么菱形路径在大多数时候也能工作)。由较长赎回路径带来的复杂性,可能会促使网络拓扑中出现中心链。 为了跟踪在链网络中沿各种路径流转的所有面额,某条链实现一个注册表可能会很有帮助,该注册表将为每种面额跟踪其“全局”源链。终端用户服务提供商(例如钱包作者)可能希望集成这样的注册表,或自行维护规范源链与人类可读名称之间的映射,以改进用户体验。

可选补充

  • 每条链在本地都可以选择维护一个查找表,在状态中使用简短、用户友好的本地面额,并在发送和接收数据包时与较长面额相互转换。
  • 可以对允许连接的其他机器以及可建立的通道施加额外限制。

向后兼容性

不适用。

向前兼容性

此初始标准在通道握手中使用版本 "ics20-1"。 此标准的未来版本可以在通道握手中使用不同的版本, 并安全地修改数据包数据格式与数据包处理程序语义。

示例实现

历史

2019 年 7 月 15 日 - 草案撰写 2019 年 7 月 29 日 - 重大修订;清理 2019 年 8 月 25 日 - 重大修订,进一步清理 2020 年 2 月 3 日 - 修订以处理成功与失败确认 2020 年 2 月 24 日 - 修订以推断 source 字段,加入版本字符串 2020 年 7 月 27 日 - 重新加入 source 字段 2022 年 11 月 11 日 - 增加 memo 字段 2023 年 9 月 22 日 - 支持多代币数据包 2024 年 3 月 5 日 - 支持路径转发 2024 年 6 月 18 日 - 支持数据 protobuf 编码

版权

本文全部内容依据 Apache 2.0 许可。

Synopsis

This standard document specifies packet data structure, state machine handling logic, and encoding details for the transfer of fungible tokens over an IBC channel between two modules on separate chains. The state machine logic presented allows for safe multi-chain denomination handling with permissionless channel opening. This logic constitutes a “fungible token transfer bridge module”, interfacing between the IBC routing module and an existing asset tracking module on the host state machine.

Motivation

Users of a set of chains connected over the IBC protocol might wish to utilise an asset issued on one chain on another chain, perhaps to make use of additional features such as exchange or privacy protection, while retaining fungibility with the original asset on the issuing chain. This application-layer standard describes a protocol for transferring fungible tokens between chains connected with IBC which preserves asset fungibility, preserves asset ownership, limits the impact of Byzantine faults, and requires no additional permissioning.

Definitions

The IBC handler interface & IBC routing module interface are as defined in ICS 25 and ICS 26, respectively.

Desired Properties

  • Preservation of fungibility (two-way peg).
  • Preservation of total supply (constant or inflationary on a single source chain & module).
  • Permissionless token transfers, no need to whitelist connections, modules, or denominations.
  • Symmetric (all chains implement the same logic, no in-protocol differentiation of hubs & zones).
  • Fault containment: prevents Byzantine-inflation of tokens originating on chain A, as a result of chain B’s Byzantine behaviour (though any users who sent tokens to chain B may be at risk).

Technical Specification

Data Structures

Only one packet data type is required: FungibleTokenPacketData, which specifies the denomination, amount, sending account, and receiving account or FungibleTokenPacketDataV2 which specifies multiple tokens being sent between sender and receiver along with an optional forwarding path that can forward tokens further beyond the initial receiving chain. A v2 supporting chain can optionally convert a v1 packet for channels that are still on version 1.
interface FungibleTokenPacketData {
  denom: string
  amount: uint256
  sender: string
  receiver: string
  memo: string
}

interface FungibleTokenPacketDataV2 {
  tokens: []Token
  sender: string
  receiver: string
  memo: string
  // a struct containing the list of next hops, 
  // determining where the tokens must be forwarded next, 
  // and the memo for the final hop
  forwarding: Forwarding 
}

interface Token {
  denom: Denom 
  amount: uint256
}

interface Denom {
  base: string // base denomination
  trace: []Hop
}

interface Forwarding {
  hops: []Hop
  memo: string
}

interface Hop {
  portId: string
  channelId: string
}
As tokens are sent across chains using the ICS 20 protocol, they begin to accrue a record of channels for which they have been transferred across. This information is encoded into the trace field in the token. The ICS 20 token traces are represented by a list of ics20Port and ics20Channel pairs, which are an ICS 20 port and channel on the current chain for which the funds exist. The port and channel pair indicate which channel the funds were previously sent through. Implementations are responsible for correctly parsing the IBC trace information and encoding it into the final on-chain denomination so that the same base denominations sent through different paths are not treated as being fungible. A sending chain may be acting as a source or sink zone. When a chain is sending tokens across a port and channel which are not equal to the last prefixed port and channel pair, it is acting as a source zone. When tokens are sent from a source zone, the destination port and channel will be prepended to the trace (once the tokens are received) adding another hop to a tokens record. When a chain is sending tokens across a port and channel which are equal to the last prefixed port and channel pair, it is acting as a sink zone. When tokens are sent from a sink zone, the first element of the trace, which was the last port and channel pair added to the trace is removed (once the tokens are received), undoing the last hop in the tokens record. A more complete explanation is present in the ibc-go implementation. The following sequence diagram exemplifies the multi-chain token transfer dynamics. This process encapsulates the steps involved in transferring tokens in a cycle that begins and ends on the same chain, traversing through chain A, chain B, and chain C. The order of operations is outlined as A -> B -> C -> A -> C -> B -> A. The forwarding path in the v2 packet tells the receiving chain where to send the tokens to next. This must be constructed as a list of portID/channelID pairs with each element concatenated as portID/channelID. This allows users to automatically route tokens through the interchain. A common usecase might be to unwind the trace of the tokens back to the original source chain before sending it forward to the final intended destination. Here are examples of the transfer packet data:

// V1 example of transfer packet data
FungibleTokenPacketData {
  denom: "transfer/channel-1/transfer/channel-4/uatom",
  amount: 500,
  sender: cosmosexampleaddr1,
  receiver: cosmosexampleaddr2,
  memo: "exampleMemo",
}

// V2 example of transfer packet data
FungibleTokenPacketDataV2 {
  tokens: [
    Token{
      denom: Denom{
        base: "uatom",
        trace: [
          Hop{
            portId: "transfer",
            channelId: "channel-1",
          }, 
          Hop{
            portId: "transfer",
            channelId: "channel-4",
          }
        ],
      },
      amount: 500,
    },
    Token{
      denom: Denom{
        base: "btc",
        trace: [
          Hop{
            portId: "transfer",
            channelId: "channel-3",
          }
        ],
      },
      amount: 7,
    }
  ],
  sender: cosmosexampleaddr1,
  receiver: cosmosexampleaddr2,
  memo: "",
  forwarding: {
    hops: [
      Hop{portId: "transfer", channelId: "channel-7"},
      Hop{portId: "transfer", channelId: "channel-13"},
    ],  
    memo: "swap: {...}"
  }, // provide hops in order and the memo intended for final hop
}
The acknowledgement data type describes whether the transfer succeeded or failed, and the reason for failure (if any).
type FungibleTokenPacketAcknowledgement = FungibleTokenPacketSuccess | FungibleTokenPacketError;

interface FungibleTokenPacketSuccess {
  // This is binary 0x01 base64 encoded
  result: "AQ=="
}

interface FungibleTokenPacketError {
  error: string
}
Note that both the FungibleTokenPacketData as well as FungibleTokenPacketAcknowledgement must be JSON-encoded (not Protobuf encoded) when they serialized into packet data. Also note that uint256 is string encoded when converted to JSON, but must be a valid decimal number of the form [0-9]+. The fungible token transfer bridge module tracks escrow addresses associated with particular channels in state. Fields of the ModuleState are assumed to be in scope.
interface ModuleState {
  channelEscrowAddresses: Map<Identifier, string>
  channelForwardingAddresses: Map<Identifier, string>
}

Store paths

Packet forward path

The v2 packets that have non-empty forwarding information and should thus be forwarded, must be stored in the private store, so that an acknowledgement can be written for them when receiving an acknowledgement or timeout for the forwarded packet.
function packetForwardPath(portIdentifier: Identifier, channelIdentifier: Identifier, sequence: uint64): Path {
  return "forwardedPackets/ports/{portIdentifier}/channels/{channelIdentifier}/sequences/{sequence}"
}

Sub-protocols

The sub-protocols described herein should be implemented in a “fungible token transfer bridge” module with access to a bank module and to the IBC routing module.

Port & channel setup

The setup function must be called exactly once when the module is created (perhaps when the blockchain itself is initialised) to bind to the appropriate port and create an escrow address (owned by the module).
function setup() {
  capability = routingModule.bindPort("transfer", ModuleCallbacks{
    onChanOpenInit,
    onChanOpenTry,
    onChanOpenAck,
    onChanOpenConfirm,
    onChanCloseInit,
    onChanCloseConfirm,
    onRecvPacket,
    onTimeoutPacket,
    onAcknowledgePacket,
    onTimeoutPacketClose
  })
  claimCapability("port", capability)
}
Once the setup function has been called, channels can be created through the IBC routing module between instances of the fungible token transfer module on separate chains. An administrator (with the permissions to create connections & channels on the host state machine) is responsible for setting up connections to other state machines & creating channels to other instances of this module (or another module supporting this interface) on other chains. This specification defines packet handling semantics only, and defines them in such a fashion that the module itself doesn’t need to worry about what connections or channels might or might not exist at any point in time.

Routing module callbacks

Channel lifecycle management
Both machines A and B accept new channels from any module on another machine, if and only if:
  • The channel being created is unordered.
  • The version string is ics20-1 or ics20-2.
function onChanOpenInit(
  order: ChannelOrder,
  connectionHops: [Identifier],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  version: string) => (version: string, err: Error) {
  // only unordered channels allowed
  abortTransactionUnless(order === UNORDERED)
  // assert that version is "ics20-1" or "ics20-2" or empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics20-2" || version === "ics20-1" || version === "")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  if version == "" {
    // default to latest supported version
    return "ics20-2", nil
  }
  // If the version is not empty and is among those supported, we return the version
  return version, nil 
}
function onChanOpenTry(
  order: ChannelOrder,
  connectionHops: [Identifier],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) => (version: string, err: Error) {
  // only unordered channels allowed
  abortTransactionUnless(order === UNORDERED)
  // assert that version is "ics20-1" or "ics20-2" 
  abortTransactionUnless(counterpartyVersion === "ics20-1" || counterpartyVersion === "ics20-2")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  // return the same version as counterparty version so long as we support it
  return counterpartyVersion, nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) {
  // port has already been validated
  // assert that counterparty selected version is the same as our version
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  abortTransactionUnless(counterpartyVersion === channel.version)
}
function onChanOpenConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
  // accept channel confirmations, port has already been validated, version has already been validated
}
function onChanCloseInit(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
    // always abort transaction
    abortTransactionUnless(FALSE)
}
function onChanCloseConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier) {
  // no action necessary
}
Packet relay
In plain English, between chains A and B:
  • When acting as the source zone, the bridge module escrows an existing local asset denomination on the sending chain and mints vouchers on the receiving chain.
  • When acting as the sink zone, the bridge module burns local vouchers on the sending chains and unescrows the local asset denomination on the receiving chain.
  • When a packet times-out, local assets are unescrowed back to the sender or vouchers minted back to the sender appropriately.
  • Acknowledgement data is used to handle failures, such as invalid denominations or invalid destination accounts. Returning an acknowledgement of failure is preferable to aborting the transaction since it more easily enables the sending chain to take appropriate action based on the nature of the failure.
Note: constructOnChainDenom is a helper function that will construct the local on-chain denomination for the bridged token. It must encode the trace and base denomination to ensure that tokens coming over different paths are not treated as fungible. The original trace and denomination must be retrievable by the state machine so that they can be passed in their original forms when constructing a new IBC path for the bridged token. The ibc-go implementation handles this by creating a local denomination: hash(trace+base_denom). sendFungibleTokens must be called by a transaction handler in the module which performs appropriate signature checks, specific to the account owner on the host state machine.
function sendFungibleTokens(
  tokens: []Token,
  sender: string,
  receiver: string,
  memo: string,
  forwarding: Forwarding,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64, // in unix nanoseconds
): uint64 {
  // memo and forwarding cannot both be non-empty
  abortTransactionUnless(memo != "" && forwarding != nil)
  for token in tokens 
    onChainDenom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // if the token is not prefixed by our channel end's port and channel identifiers
    // then we are sending as a source zone
    if !isTracePrefixed(sourcePort, sourceChannel, token) {
      // determine escrow account
      escrowAccount = channelEscrowAddresses[sourceChannel]
      // escrow source tokens (assumed to fail if balance insufficient)
      bank.TransferCoins(sender, escrowAccount, onChainDenom, token.amount)
    } else {
      // receiver is source chain, burn vouchers
      bank.BurnCoins(sender, onChainDenom, token.amount)
    }
  }

  var dataBytes bytes
  channel = provableStore.get(channelPath(sourcePort, sourceChannel))
  // getAppVersion returns the transfer version that is embedded in the channel version
  // as the channel version may contain additional app or middleware version(s)
  transferVersion = getAppVersion(channel.version)
  if transferVersion == "ics20-1" {
    abortTransactionUnless(len(tokens) == 1)
    token = tokens[0]
    // abort if forwarding defined
    abortTransactionUnless(forwarding == nil)
    // create v1 denom of the form: port1/channel1/port2/channel2/port3/channel3/denom
    v1Denom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // v1 packet data does not support forwarding fields
    data = FungibleTokenPacketData{v1Denom, token.amount, sender, receiver, memo}
    // JSON-marshal packet data into bytes
    dataBytes = json.marshal(data)
  } else if transferVersion == "ics20-2" {
    // create FungibleTokenPacket data
    data = FungibleTokenPacketDataV2{tokens, sender, receiver, memo, forwarding}
    // protobuf-marshal packet data into bytes
    dataBytes = protobuf.marshal(data)
  } else {
    // should never be reached as transfer version must be negotiated to be either
    // ics20-1 or ics20-2 during channel handshake
    abortTransactionUnless(false)
  }

  // send packet using the interface defined in ICS4
  sequence = handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    dataBytes,
  )

  return sequence
}
onRecvPacket is called by the routing module when a packet addressed to this module has been received. Note: Function parseICS20V1Denom is a helper function that will take the full IBC denomination and extract the base denomination (i.e. native denomination in the chain of origin) and the trace information (if any) for the received token.
function onRecvPacket(packet: Packet) {
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  // getAppVersion returns the transfer version that is embedded in the channel version
  // as the channel version may contain additional app or middleware version(s)
  transferVersion = getAppVersion(channel.version)
  var tokens []Token
  var sender string
  var receiver string // address to send tokens to on this chain
  var finalReceiver string // final intended address in forwarding case

  if transferVersion == "ics20-1" {
     FungibleTokenPacketData data = json.unmarshal(packet.data)
     // convert full denom string to denom struct with base denom and trace
     denom = parseICS20V1Denom(data.denom)
     token = Token{
       denom: denom
       amount: data.amount
     }
     tokens = []Token{token}
     sender = data.sender
     receiver = data.receiver
  } else if transferVersion == "ics20-2" {
    FungibleTokenPacketDataV2 data = protobuf.unmarshal(packet.data)
    tokens = data.tokens
    sender = data.sender

    // if we need to forward the tokens onward
    // overwrite the receiver to temporarily send to the 
    // channel escrow address of the intended receiver
    if len(data.forwarding.hops) > 0 {
      // memo must be empty
      abortTransactionUnless(data.memo == "")
      if channelForwardingAddress[packet.destChannel] == "" {
        channelForwardingAddress[packet.destChannel] = newAddress()
      }
      receiver = channelForwardingAddresses[packet.destChannel]
      finalReceiver = data.receiver
    } else {
      receiver = data.receiver
    }
  } else {
    // should never be reached as transfer version must be negotiated
    // to be either ics20-1 or ics20-2 during channel handshake
    abortTransactionUnless(false)
  }

  assert(sender !== "")
  assert(receiver !== "")
    
  // construct default acknowledgement of success
  FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, null}

  receivedTokens = []Token
  for token in tokens {
    assert(token.denom !== nil)
    assert(token.amount > 0)
    
    var onChainTrace []Hop
    // we are the source if the packets were prefixed by the sending chain
    // if the sender sends the tokens prefixed with their channel end's
    // port and channel identifiers then we are receiving tokens we 
    // previously had sent to the sender, thus we are receiving the tokens
    // as a source zone
    if isTracePrefixed(packet.sourcePort, packet.sourceChannel, token) {
      // since we are receiving back to source we remove the prefix from the trace
      onChainTrace = token.trace[1:]
      onChainDenom = constructOnChainDenom(onChainTrace, token.denom.base)
      // receiver is source chain: unescrow tokens
      // determine escrow account
      escrowAccount = channelEscrowAddresses[packet.destChannel]
      // unescrow tokens to receiver (assumed to fail if balance insufficient)
      err = bank.TransferCoins(escrowAccount, receiver, onChainDenom, token.amount)
      if (err != nil) {
        ack = FungibleTokenPacketAcknowledgement{false, "transfer coins failed"}
        // break out of for loop on first error
        break
      }
    } else {
      // since we are receiving to a new sink zone we prepend the prefix to the trace
      prefixTrace = Hop{portId: packet.destPort, channelId: packet.destChannel}
      onChainTrace = append([]Hop{prefixTrace}, token.denom.trace...)
      onChainDenom = constructOnChainDenom(onChainTrace, token.denom.base)
      // sender was source, mint vouchers to receiver (assumed to fail if balance insufficient)
      err = bank.MintCoins(receiver, onChainDenom, token.amount)
      if (err !== nil) {
        ack = FungibleTokenPacketAcknowledgement{false, "mint coins failed"}
        // break out of for loop on first error
        break
      }
    }

    // add the received token to the received tokens list
    recvToken = Token{
      denom: Denom{base: token.denom.base, trace: onChainTrace},
      amount: token.amount,
    }
    receivedTokens = append(receivedTokens, recvToken)
  }

  // if there is an error ack return immediately and do not forward further
  if !ack.Success() {
    return ack
  }

  // if acknowledgement is successful and forwarding path set
  // then start forwarding
  if len(forwarding.hops) > 0 {
    //check that next channel supports token forwarding
    channel = provableStore.get(channelPath(forwarding.hops[0].portId, forwarding.hops[0].channelId))
    if channel.version != "ics20-2" && len(forwarding.hops) > 1 {
      ack = FungibleTokenPacketAcknowledgement(false, "next hop in path cannot support forwarding onward")
      return ack
    }
    memo = ""
    nextForwarding = Forwarding{
      hops: forwarding.hops[1:]
      memo: forwarding.memo
    }
    if len(forwarding.hops) == 1 {
      // we're on the last hop, we can set memo and clear
      // the next forwarding
      memo = forwarding.memo
      nextForwarding = nil
    }
    // send the tokens we received above to the next port and channel
    // on the forwarding path
    // and reduce the forwarding by the first element
    packetSequence = sendFungibleTokens(
      receivedTokens,
      receiver, // sender of next packet
      finalReceiver, // receiver of next packet
      memo,
      nextForwarding,
      forwarding.hops[0].portId,
      forwarding.hops[0].channelId,
      Height{},
      currentTime() + DefaultHopTimeoutPeriod,
    )
    // store packet for future sending ack
    privateStore.set(packetForwardPath(forwarding.hops[0].portId, forwarding.hops[0].channelId, packetSequence), packet)
    // use async ack until we get successful acknowledgement from further down the line.
    return nil
  }

  return ack
}
onAcknowledgePacket is called by the routing module when a packet sent by this module has been acknowledged.
function onAcknowledgePacket(
  packet: Packet,
  acknowledgement: bytes) {
  // if the transfer failed, refund the tokens
  // to the sender account. In case of a packet sent for a
  // forwarded packet, the sender is the forwarding
  // address for the destination channel of the forwarded packet.
  if !(acknowledgement.success) {
    refundTokens(packet)
  }

  // check if the packet that was sent is from a previously forwarded packet
  prevPacket = privateStore.get(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))

  if prevPacket != nil {
    if acknowledgement.success {
      FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, "forwarded packet succeeded"}
      handler.writeAcknowledgement(
        prevPacket,
        ack,
      )
    } else {
      // the forwarded packet has failed, thus the funds have been refunded to the forwarding address.
      // we must revert the changes that came from successfully receiving the tokens on our chain
      // before propogating the error acknowledgement back to original sender chain
      revertInFlightChanges(packet, prevPacket)
      // write error acknowledgement
      FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{false, "forwarded packet failed"}
      handler.writeAcknowledgement(
        prevPacket,
        ack,
      )
    }

    // delete the forwarded packet that triggered sending this packet
    privateStore.delete(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))
  }
}
onTimeoutPacket is called by the routing module when a packet sent by this module has timed-out (such that it will not be received on the destination chain).
function onTimeoutPacket(packet: Packet) {
  // the packet timed-out, so refund the tokens
  // to the sender account. In case of a packet sent for a
  // forwarded packet, the sender is the forwarding
  // address for the destination channel of the forwarded packet.
  refundTokens(packet)

  // check if the packet sent is from a previously forwarded packet
  prevPacket = privateStore.get(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))

  if prevPacket != nil {
    // the forwarded packet has failed, thus the funds have been refunded to the forwarding address.
    // we must revert the changes that came from successfully receiving the tokens on our chain
    // before propogating the error acknowledgement back to original sender chain
    revertInFlightChanges(packet, prevPacket)
    // write error acknowledgement
    FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{false, "forwarded packet timed out"}
    handler.writeAcknowledgement(
      prevPacket,
      ack,
    )

    // delete the forwarded packet that triggered sending this packet
    privateStore.delete(packetForwardPath(packet.sourcePort, packet.sourceChannel, packet.sequence))
  }
}
Given three chains and a transfer from chain A to chain C through chain B, the following diagrams summarize the core logic of the protocol regarding the handling of tokens in the middle chain, both for the success case (i.e. tokens received on chain C) and failure case (i.e. tokens cannot be received on chain C and an error acknowledgement is written): Forwarding success case Forwarding failure case
Helper functions
// helper function that returns true if the first element of the trace of the 
// token is matches the provided portId and channelId; otherwise it returns false
function isTracePrefixed(portId: string, channelId: string, token: Token) boolean {
  trace = token.trace[0]
  return trace.portId == portId && trace.channelId == channelId
}
refundTokens is called by both onAcknowledgePacket, on failure, and onTimeoutPacket, to refund escrowed tokens to the original sender.
function refundTokens(packet: Packet) {
  channel = provableStore.get(channelPath(portIdentifier, channelIdentifier))
  // getAppVersion returns the transfer version that is embedded in the channel version
  // as the channel version may contain additional app or middleware version(s)
  transferVersion = getAppVersion(channel.version)
  if transferVersion == "ics20-1" {
     FungibleTokenPacketData data = json.unmarshal(packet.data)
     // convert full denom string to denom struct with base denom and trace
     denom = parseICS20V1Denom(data.denom)
     token = Token{
       denom: denom
       amount: data.amount
     }
     tokens = []Token{token}
  } else if transferVersion == "ics20-2" {
    FungibleTokenPacketDataV2 data = protobuf.unmarshal(packet.data)
    tokens = data.tokens
  } else {
    // should never be reached as transfer version must be negotiated to be either
    // ics20-1 or ics20-2 during channel handshake
    abortTransactionUnless(false)
  }

  for token in tokens {
    onChainDenom = constructOnChainDenom(token.denom.trace, token.denom.base)
    // Since this is refunding an outgoing packet, we can check if the tokens 
    // were originally from the receiver by checking if the tokens were prefixed
    // by our channel end's identifiers.
    if !isTracePrefixed(packet.sourcePort, packet.sourceChannel, token) {
      // sender was source chain, unescrow tokens back to sender
      escrowAccount = channelEscrowAddresses[packet.sourceChannel]
      bank.TransferCoins(escrowAccount, data.sender, onChainDenom, token.amount)
    } else {
      // receiver was source chain, mint vouchers back to sender
      bank.MintCoins(data.sender, onChainDenom, token.amount)
    }
  }
}
// revertInFlightChanges reverts the receive packet
// that occurs in the middle chains during a packet forwarding
// If an error occurs further down the line, the state changes
// on this chain must be reverted before sending back the error acknowledgement
// to ensure atomic packet forwarding
function revertInFlightChanges(sentPacket: Packet, receivedPacket: Packet) {
  forwardingAddress = channelForwardingAddress[receivedPacket.destChannel]
  reverseEscrow = channelEscrowAddresses[receivedPacket.destChannel]

  // the token on our chain is the token in the sentPacket
  for token in sentPacket.tokens {
    // we are checking if the tokens that were sent out by our chain in the 
    // sentPacket were source tokens with respect to the original receivedPacket.
    // If the tokens in sentPacket were prefixed by our channel end's port and channel
    // identifiers, then it was a minted voucher and we need to burn it.
    // Otherwise, it was an original token from our chain and we must give the tokens
    // back to the escrow account.
    if !isTracePrefixed(receivedPacket.destinationPort, receivedPacket.destinationChannel, token) {
      // receive sent tokens from the received escrow account to the forwarding account
      // so we must send the tokens back from the forwarding account to the received escrow account
      bank.TransferCoins(forwardingAddress, reverseEscrow, token.denom, token.amount)
    } else {
      // receive minted vouchers and sent to the forwarding account
      // so we must burn the vouchers from the forwarding account
      bank.BurnCoins(forwardingAddress, token.denom, token.amount)
    }
  }
}
function onTimeoutPacketClose(packet: Packet) {
  // can't happen, only unordered channels allowed
}

Using the Memo Field

Note: Since earlier versions of this specification did not include a memo field, implementations must ensure that the new packet data is still compatible with chains that expect the old packet data. A legacy implementation MUST be able to unmarshal a new packet data with an empty string memo into the legacy FungibleTokenPacketData struct. Similarly, an implementation supporting memo must be able to unmarshal a legacy packet data into the current struct with the memo field set to the empty string. The memo field is not used within transfer, however it may be used either for external off-chain users (i.e. exchanges) or for middleware wrapping transfer that can parse and execute custom logic on the basis of the passed in memo. If the memo is intended to be parsed and interpreted by higher-level middleware, then these middleware are advised to namespace their additions to the memo string so that they do not overwrite each other. Chains should ensure that there is some length limit on the entire packet data to ensure that the packet does not become a DOS vector. However, these do not need to be protocol-defined limits. If the receiver cannot accept a packet because of length limitations, this will lead to a timeout on the sender side. Memos that are intended to be read by higher level middleware for custom execution must be structured so that different middleware can read relevant data in the memo intended for them without interfering with data intended for other middlewares. Thus, for any memo that is meant to be interpreted by the state machine; it is recommended that the memo is a JSON object with each middleware reserving a key that it can read into and retrieve relevant data. This way the memo can be constructed to pass in information such that multiple middleware can read the memo without interference from each other. Example:
{
  "wasm": {
    "address": "contractAddress",
    "arguments": "marshalledArguments",
  },
  "callback": "contractAddress",
  "router": "routerArgs",
}
Here, the “wasm”, “callback”, and “router” fields are all intended for separate middlewares that will exclusively read those fields respectively in order to execute their logic. This allows multiple modules to read from the memo. Middleware should take care to reserve a unique key so that they do not accidentally read data intended for a different module. This issue can be avoided by some off-chain registry of keys already in-use in the JSON object.

Reasoning

Correctness
This implementation preserves both fungibility & supply. Fungibility: If tokens have been sent to the counterparty chain, they can be redeemed back in the same denomination & amount on the source chain. Supply: Redefine supply as unlocked tokens. All send-recv pairs sum to net zero. Source chain can change supply.
Multi-chain notes
This specification does not directly handle the “diamond problem”, where a user sends a token originating on chain A to chain B, then to chain D, and wants to return it through D -> C -> A — since the supply is tracked as owned by chain B (and the denomination will be “////denom”), chain C cannot serve as the intermediary. It is not yet clear whether that case should be dealt with in-protocol or not — it may be fine to just require the original path of redemption (and if there is frequent liquidity and some surplus on both paths the diamond path will work most of the time). Complexities arising from long redemption paths may lead to the emergence of central chains in the network topology. In order to track all of the denominations moving around the network of chains in various paths, it may be helpful for a particular chain to implement a registry which will track the “global” source chain for each denomination. End-user service providers (such as wallet authors) may want to integrate such a registry or keep their own mapping of canonical source chains and human-readable names in order to improve UX.

Optional addenda

  • Each chain, locally, could elect to keep a lookup table to use short, user-friendly local denominations in state which are translated to and from the longer denominations when sending and receiving packets.
  • Additional restrictions may be imposed on which other machines may be connected to & which channels may be established.

Backwards Compatibility

Not applicable.

Forwards Compatibility

This initial standard uses version “ics20-1” in the channel handshake. A future version of this standard could use a different version in the channel handshake, and safely alter the packet data format & packet handler semantics.

Example Implementations

History

Jul 15, 2019 - Draft written Jul 29, 2019 - Major revisions; cleanup Aug 25, 2019 - Major revisions, more cleanup Feb 3, 2020 - Revisions to handle acknowledgements of success & failure Feb 24, 2020 - Revisions to infer source field, inclusion of version string July 27, 2020 - Re-addition of source field Nov 11, 2022 - Addition of a memo field Sep 22, 2023 - Support for multi-token packets March 5, 2024 - Support for path forwarding June 18, 2024 - Support for data protobuf encoding All content herein is licensed under Apache 2.0.