概要

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

动机

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

定义

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

期望属性

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

技术规范

数据结构

只需要一种数据包类型:FungibleTokenPacketData,它指定面额、数量、发送账户和接收账户。
interface FungibleTokenPacketData {
  denom: string
  amount: uint256
  sender: string
  receiver: string
  memo: string
}
当代币使用 ICS 20 协议跨链发送时,它们会开始累积一份记录,标明其曾经跨越过哪些通道。这些信息会被编码到 denom 字段中。 ICS 20 代币面额表示为 {ics20Port}/{ics20Channel}/{denom} 的形式,其中 ics20Port 和 ics20Channel 是当前链上该资金所在的 ICS 20 端口和通道。前缀中的端口与通道对表明该资金此前曾通过哪个通道发送。实现必须负责从基础面额中正确解析 IBC 追踪信息。ibc-go 中参考 ICS 20 实现的处理方式,是利用其自动生成 channel-{n} 格式的通道标识符这一事实,其中 n 是大于等于 0 的整数。这样,它就可以从可能包含斜杠、但不会包含形如 {transfer-port-name}/channel-{n} 子串的基础 denom 中正确解析出 IBC 追踪信息。如果这一假设被破坏,追踪信息就会被错误解析(也就是基础 denom 的一部分会被误解释为追踪信息)。因此,各链必须确保基础面额不能构造出能够伪装 ICS 20 逻辑的任意前缀。 发送链可以充当源区或汇区。当一条链通过某个端口和通道发送代币,而该端口和通道不等于最后一个前缀端口与通道对时,它充当源区。当代币从源区发出时,目标端口和通道会在接收后被加到面额前缀中,从而为代币记录增加一次新的跳转。当一条链通过某个端口和通道发送代币,而该端口和通道等于最后一个前缀端口与通道对时,它充当汇区。当代币从汇区发出时,面额中的最后一个前缀端口与通道对会在接收后被移除,从而撤销代币记录中的最后一次跳转。更完整的解释见 ibc-go implementation 和 ADR 001。 下列时序图展示了多链代币转移的动态过程。该过程涵盖了一个以同一条链为起点和终点的循环转移步骤,路径经过 Chain A、Chain B 和 Chain C。操作顺序为 A -> B -> C -> A -> C -> B -> A。 确认数据类型用于描述转移是成功还是失败,以及失败原因(如果有)。
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>
}

子协议

本文描述的子协议应实现于一个“同质化代币转移桥接”模块中,并且该模块需要能够访问 bank 模块和 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。
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 empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics20-1" || version === "")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  return "ics20-1", 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"
  abortTransactionUnless(counterpartyVersion === "ics20-1")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  // return version that this chain will use given the
  // counterparty version
  return "ics20-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) {
  // port has already been validated
  // assert that counterparty selected version is "ics20-1"
  abortTransactionUnless(counterpartyVersion === "ics20-1")
}
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 之间:
  • 当作为源区域时,桥接模块会在发送链上托管现有的本地资产面额,并在接收链上铸造凭证。
  • 当作为汇区域时,桥接模块会在发送链上销毁本地凭证,并在接收链上解除托管本地资产面额。
  • 当数据包超时时,会根据情况将本地资产解除托管并返还给发送者,或重新向发送者铸造凭证。
  • 确认数据用于处理失败情况,例如无效面额或无效目标账户。返回失败确认比中止交易更可取,因为这样更容易让发送链根据失败的具体性质采取适当行动。
sendFungibleTokens 必须由模块中的交易处理程序调用,并执行与宿主状态机上的账户所有者相对应的适当签名校验。
function sendFungibleTokens(
  denomination: string,
  amount: uint256,
  sender: string,
  receiver: string,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64, // in unix nanoseconds
): uint64 {
    prefix = "{sourcePort}/{sourceChannel}/"
    // we are the source if the denomination is not prefixed
    source = denomination.slice(0, len(prefix)) !== prefix
    if source {
      // determine escrow account
      escrowAccount = channelEscrowAddresses[sourceChannel]
      // escrow source tokens (assumed to fail if balance insufficient)
      bank.TransferCoins(sender, escrowAccount, denomination, amount)
    } else {
      // receiver is source chain, burn vouchers
      bank.BurnCoins(sender, denomination, amount)
    }

    // create FungibleTokenPacket data
    data = FungibleTokenPacketData{denomination, amount, sender, receiver}

    // send packet using the interface defined in ICS4
    sequence = handler.sendPacket(
      getCapability("port"),
      sourcePort,
      sourceChannel,
      timeoutHeight,
      timeoutTimestamp,
      json.marshal(data) // json-marshalled bytes of packet data
    )

    return sequence
}
当路由模块收到发送到本模块的数据包时,会调用 onRecvPacket。
function onRecvPacket(packet: Packet) {
  FungibleTokenPacketData data = packet.data
  assert(data.denom !== "")
  assert(data.amount > 0)
  assert(data.sender !== "")
  assert(data.receiver !== "")

  // construct default acknowledgement of success
  FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, null}
  prefix = "{packet.sourcePort}/{packet.sourceChannel}/"
  // we are the source if the packets were prefixed by the sending chain
  source = data.denom.slice(0, len(prefix)) === prefix
  if source {
    // 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, data.receiver, data.denom.slice(len(prefix)), data.amount)
    if (err !== nil)
      ack = FungibleTokenPacketAcknowledgement{false, "transfer coins failed"}
  } else {
    prefix = "{packet.destPort}/{packet.destChannel}/"
    prefixedDenomination = prefix + data.denom
    // sender was source, mint vouchers to receiver (assumed to fail if balance insufficient)
    err = bank.MintCoins(data.receiver, prefixedDenomination, data.amount)
    if (err !== nil)
      ack = FungibleTokenPacketAcknowledgement{false, "mint coins failed"}
  }
  return ack
}
当路由模块确认了由本模块发送的数据包时,会调用 onAcknowledgePacket。
function onAcknowledgePacket(
  packet: Packet,
  acknowledgement: bytes) {
  // if the transfer failed, refund the tokens
  if (!acknowledgement.success)
    refundTokens(packet)
}
当路由模块判定由本模块发送的数据包已超时(因此不会被目标链接收)时,会调用 onTimeoutPacket。
function onTimeoutPacket(packet: Packet) {
  // the packet timed-out, so refund the tokens
  refundTokens(packet)
}
refundTokens 会在 onAcknowledgePacket 失败时以及 onTimeoutPacket 中被调用,用于将托管的代币退还给原始发送者。
function refundTokens(packet: Packet) {
  FungibleTokenPacketData data = packet.data
  prefix = "{packet.sourcePort}/{packet.sourceChannel}/"
  // we are the source if the denomination is not prefixed
  source = data.denom.slice(0, len(prefix)) !== prefix
  if source {
    // sender was source chain, unescrow tokens back to sender
    escrowAccount = channelEscrowAddresses[packet.srcChannel]
    bank.TransferCoins(escrowAccount, data.sender, data.denom, data.amount)
  } else {
    // receiver was source chain, mint vouchers back to sender
    bank.MintCoins(data.sender, data.denom, data.amount)
  }
}
function onTimeoutPacketClose(packet: Packet) {
  // can't happen, only unordered channels allowed
}

使用 memo 字段

注意:由于本规范的早期版本不包含 memo 字段,实现必须确保新的数据包数据仍与期望旧数据包数据的链兼容。旧版实现必须能够将带有空字符串 memo 的新数据包数据反序列化为旧版 FungibleTokenPacketData 结构体。同样,支持 memo 的实现必须能够将旧版数据包数据反序列化为当前结构体,并将 memo 字段设为空字符串。 memo 字段在转账内部不会被使用,但它可以供外部链下用户(例如交易所)使用,也可以供包装转账的中间件使用;后者能够解析 memo 并基于其内容执行自定义逻辑。如果 memo 计划由更高层中间件解析和解释,建议这些中间件对其添加内容进行命名空间划分,以避免彼此覆盖。各链应确保整个数据包数据存在某种长度限制,以防止数据包成为 DoS 攻击向量。不过,这些限制不需要由协议定义。如果接收方因长度限制无法接受数据包,发送方一侧将发生超时。 如果 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 字段

版权

此处所有内容均依据 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.
interface FungibleTokenPacketData {
  denom: string
  amount: uint256
  sender: string
  receiver: string
  memo: 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 denom field. The ICS 20 token denominations are represented by the form {ics20Port}/{ics20Channel}/{denom}, where ics20Port and ics20Channel are an ICS 20 port and channel on the current chain for which the funds exist. The prefixed port and channel pair indicate which channel the funds were previously sent through. Implementations are responsible for correctly parsing the IBC trace information from the base denomination. The way the reference ICS 20 implementation in ibc-go handles this is by taking advantage of the fact that it automatically generates channel identifiers with the format channel-{n}, where n is a integer greater or equal than 0. It can then correctly parse out the IBC trace information from the base denom which may have slashes, but will not have a substring of the form {transfer-port-name}/channel-{n}. If this assumption is broken, the trace information will be parsed incorrectly (i.e. part of the base denom will be misinterpreted as trace information). Thus chains must make sure that base denominations do not have the ability to create arbitrary prefixes that can mock the ICS 20 logic. 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 prefixed onto the denomination (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 last prefixed port and channel pair on the denomination 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 and the ADR 001. 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 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>
}

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.
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 empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics20-1" || version === "")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  return "ics20-1", 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"
  abortTransactionUnless(counterpartyVersion === "ics20-1")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress(portIdentifier, channelIdentifier)
  // return version that this chain will use given the
  // counterparty version
  return "ics20-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string) {
  // port has already been validated
  // assert that counterparty selected version is "ics20-1"
  abortTransactionUnless(counterpartyVersion === "ics20-1")
}
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.
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(
  denomination: string,
  amount: uint256,
  sender: string,
  receiver: string,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64, // in unix nanoseconds
): uint64 {
    prefix = "{sourcePort}/{sourceChannel}/"
    // we are the source if the denomination is not prefixed
    source = denomination.slice(0, len(prefix)) !== prefix
    if source {
      // determine escrow account
      escrowAccount = channelEscrowAddresses[sourceChannel]
      // escrow source tokens (assumed to fail if balance insufficient)
      bank.TransferCoins(sender, escrowAccount, denomination, amount)
    } else {
      // receiver is source chain, burn vouchers
      bank.BurnCoins(sender, denomination, amount)
    }

    // create FungibleTokenPacket data
    data = FungibleTokenPacketData{denomination, amount, sender, receiver}

    // send packet using the interface defined in ICS4
    sequence = handler.sendPacket(
      getCapability("port"),
      sourcePort,
      sourceChannel,
      timeoutHeight,
      timeoutTimestamp,
      json.marshal(data) // json-marshalled bytes of packet data
    )

    return sequence
}
onRecvPacket is called by the routing module when a packet addressed to this module has been received.
function onRecvPacket(packet: Packet) {
  FungibleTokenPacketData data = packet.data
  assert(data.denom !== "")
  assert(data.amount > 0)
  assert(data.sender !== "")
  assert(data.receiver !== "")

  // construct default acknowledgement of success
  FungibleTokenPacketAcknowledgement ack = FungibleTokenPacketAcknowledgement{true, null}
  prefix = "{packet.sourcePort}/{packet.sourceChannel}/"
  // we are the source if the packets were prefixed by the sending chain
  source = data.denom.slice(0, len(prefix)) === prefix
  if source {
    // 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, data.receiver, data.denom.slice(len(prefix)), data.amount)
    if (err !== nil)
      ack = FungibleTokenPacketAcknowledgement{false, "transfer coins failed"}
  } else {
    prefix = "{packet.destPort}/{packet.destChannel}/"
    prefixedDenomination = prefix + data.denom
    // sender was source, mint vouchers to receiver (assumed to fail if balance insufficient)
    err = bank.MintCoins(data.receiver, prefixedDenomination, data.amount)
    if (err !== nil)
      ack = FungibleTokenPacketAcknowledgement{false, "mint coins failed"}
  }
  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
  if (!acknowledgement.success)
    refundTokens(packet)
}
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
  refundTokens(packet)
}
refundTokens is called by both onAcknowledgePacket, on failure, and onTimeoutPacket, to refund escrowed tokens to the original sender.
function refundTokens(packet: Packet) {
  FungibleTokenPacketData data = packet.data
  prefix = "{packet.sourcePort}/{packet.sourceChannel}/"
  // we are the source if the denomination is not prefixed
  source = data.denom.slice(0, len(prefix)) !== prefix
  if source {
    // sender was source chain, unescrow tokens back to sender
    escrowAccount = channelEscrowAddresses[packet.srcChannel]
    bank.TransferCoins(escrowAccount, data.sender, data.denom, data.amount)
  } else {
    // receiver was source chain, mint vouchers back to sender
    bank.MintCoins(data.sender, data.denom, data.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 All content herein is licensed under Apache 2.0.