Skip to content
Cosmopediaby Unity Nodes
Documentationcosmos/ibc (ICS specifications)cosmos/ibc › spec › app › ics-020-fungible-token-transfer › deprecatedView on cosmos/ibc (ICS specifications) ↗

README


ics: 20 title: Fungible Token Transfer version: 2 stage: draft category: IBC/APP requires: 25, 26 kind: instantiation version compatibility: author: Christopher Goes [email protected], Aditya Sripal [email protected] created: 2019-07-15 modified: 2024-03-05

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.

sequenceDiagram
    Note over chain A,chain B: A is source zone: A -> B
    chain A->>chain A: Lock (escrow) tokens ("denom")
    chain A->>chain B: Send transfer packet with tokens ("denom")
    chain B->>chain B: Mint vouchers ("transfer/ChannelToA/denom")
    Note over chain B,chain C: B is source zone: B -> C
    chain B->>chain B: Lock (escrow) vouchers ("transfer/ChannelToA/denom")
    chain B->>chain C: Send transfer packet with vouchers ("transfer/ChannelToA/denom")
    chain C->>chain C: Mint vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    Note over chain A,chain C: C is source zone: C -> A
    chain C->>chain C: Lock (escrow) vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain C->>chain A: Send transfer packet with vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain A->>chain A: Mint vouchers ("tansfer/ChannelToC/transfer/ChannelToB/transfer/ChannelToA/denom")
    Note over chain A,chain C: A is sink zone: A -> C
    chain A->>chain A: Burn vouchers ("transfer/ChannelToC/transfer/ChannelToB/transfer/ChannelToA/denom")
    chain A->>chain C: Send transfer packet with vouchers ("transfer/ChannelToC/transfer/ChannelToB/transfer/ChannelToA/denom")
    chain C->>chain C: Unlock (unescrow) vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    Note over chain B,chain C: C is sink zone: C -> B
    chain C->>chain C: Burn vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain C->>chain B: Send transfer packet with vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain B->>chain B: Unlock (unescrow) vouchers ("transfer/ChannelToA/denom")
    Note over chain B,chain A: B is sink zone: B -> A
    chain B->>chain B: Burn vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain B->>chain A: Send transfer packet with vouchers ("transfer/ChannelToB/transfer/ChannelToA/denom")
    chain A->>chain A: Unlock (unescrow) vouchers ("transfer/ChannelToA/denom")

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 =

Excerpt (19979 of 38384 characters). Read the whole page on cosmos/ibc (ICS specifications) ↗