本规范已经过规范委员会审阅,但不由其负责维护。

概述

本文档规定了在两条独立链上的两个模块之间,通过 IBC 通道进行可替代代币原子交换时的数据包结构、状态机处理逻辑以及编码细节。

动机

用户可能希望在不将代币转出其原生链的情况下完成代币交换。启用 ICS-100 的链可以支持用户及其分别位于不同链上的代币之间进行原子交换。这对于特定用户之间按特定价格进行交易非常有用,也为新的应用设计打开了空间。 例如,相比使用 ICS-20 时需要多笔交易,代币交换对用户而言只需要一笔交易。此外,在存在愿意成交的对手方时,用户相较于使用流动性池,可以尽量减少交易滑点。

定义

原子交换:来自不同链上的代币交换过程中,不需要将代币从一条区块链转移到另一条区块链。交换要么发生,要么不发生,不存在第三种结果。 订单:以数量 X 的代币 A 交换数量 Y 的代币 B 的报价。被报价的代币会被发送到一个由模块持有的托管账户。 挂单方:创建或发起订单的用户。 吃单方:接受或响应订单的对手方。 挂单方链:挂单方创建或发起订单所在的区块链。 吃单方链:吃单方接受或响应订单所在的区块链。

期望属性

  • 无许可:无需对白名单连接、模块或币种进行配置。
  • 交换保证:不会出现某个用户在未履行等值承诺交换的情况下收到代币。
  • 支持托管:由模块拥有的账户将持有代币并促成交换。
  • 可退款:当发生超时或订单被取消时,托管中的代币会被退回。
  • 订单取消:没有吃单方的订单可以被取消。
  • 基础订单簿:存储订单并作为订单簿系统运行。
  • 原子性:一种代币交换另一种代币时,结果要么完全成功,要么完全失败。

技术规范

总体设计

IBC 交换示意图 挂单方通过创建订单,提出用代币 A 交换代币 B。订单会指定交换数量和价格,并将用于报价的代币 A 发送到挂单方所在链上的托管账户。任何位于其他链上且持有代币 B 的吃单方,都可以通过接受订单来完成交易。吃单方会将所需数量的代币 B 发送到吃单方所在链上的托管账户。随后,两条链上各自的托管账户会将相应数量的代币转给双方用户的收款地址,而无需执行常规的 IBC 转账。 没有吃单方的订单可以被取消。这使用户能够纠正错误,例如输入错误的价格或吃单方地址。订单取消后,被托管的代币将被退回。 创建或接受订单时,都会在中继的数据包中指定一个超时时间窗口。发生超时后,被托管的代币将被退回。该超时时间窗口是可自定义的。

数据结构

只需要一种数据包类型:AtomicSwapPacketData,它指定交换消息类型、数据(protobuf 编组)以及 memo 字段。
enum SwapMessageType {
  // Default zero value enumeration
  TYPE_UNSPECIFIED = 0,
  TYPE_MSG_MAKE_SWAP = 1,
  TYPE_MSG_TAKE_SWAP = 2,
  TYPE_MSG_CANCEL_SWAP = 3,
}
// AtomicSwapPacketData is comprised of a swap message type, raw transaction and optional memo field.
interface AtomicSwapPacketData {
  type: SwapMessageType
  data: bytes
  memo: string
}
type AtomicSwapPacketAcknowledgement = AtomicSwapPacketSuccess | AtomicSwapPacketError
interface AtomicSwapPacketSuccess {
  // This is binary 0x01 base64 encoded
  result: "AQ=="
}

interface AtomicSwapPacketError {
  error: string
}
所有 AtomicSwapPacketData 都会根据其类型转发给对应的消息处理器执行。共有 3 种类型:
interface MakeSwapMsg {
  // the port on which the packet will be sent, specified by the maker when the message is created
  sourcePort: string
  // the channel on which the packet will be sent, specified by the maker when the message is created
  sourceChannel: string
  // the tokens to be exchanged
  sellToken: Coin
  buyToken: Coin
  // the maker's address
  makerAddress: string
  // the maker's address on the taker chain
  makerReceivingAddress string
  // if desiredTaker is specified,
  // only the desiredTaker is allowed to take this order
  // this is the address on the taker chain
  desiredTaker: string
  creationTimestamp: uint64
  expirationTimestamp: uint64
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
interface TakeSwapMsg {
  orderId: string
  // the tokens to be sold
  sellToken: Coin
  // the taker's address
  takerAddress: string
  // the taker's address on the maker chain
  takerReceivingAddress: string
  creationTimestamp: uint64
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
interface CancelSwapMsg {
  orderId: string
  makerAddress: string
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
其中,Coin 是一个由数量和币种组成的接口:
interface Coin {
  amount: int64
  denom: string
}
挂单方链和吃单方链都会分别维护各自的订单存储。订单会同时保存在挂单方链和吃单方链上。
// Status of order 
enum Status {
  INITIAL = 0, // initialed on maker chain
  SYNC = 1, // synced to the taker chain
  CANCEL = 2, // canceled 
  COMPLETE = 3, // completed
}

interface Order {
  id: string
  maker: MakeSwapMsg
  status: Status
  // an IBC path, define channel and port on both Maker Chain and Taker Chain 
  path: string
  taker: TakeSwapMsg
  cancelTimestamp: uint64
  completeTimestamp: uint64
}

function createOrder(msg: MakeSwapMsg, packet: channelType.Packet): Order {
    const path = orderPath(packet)
    return Order{
      id : generateOrderId(packet),
      status: Status.INITIAL,
      path: path,
      maker: msg,
    }
  }

function orderPath(packet: channelType.Packet): string {
    return `channel/${packet.sourceChannel}/port/${packet.sourcePort}/channel/${packet.destChannel}/port/${packet.destPort}/sequence/${packet.sequence}`

}
// Order id is a global unique string, since packet contains sourceChannel, SourcePort, distChannel, distPort, sequence and msg data
function generateOrderId(packet: channelType.Packet) : string {
  const bytes = protobuf.encode(packet)
  return sha265(bytes)
}

function extractSourceChannelForTakerMsg(path: string) : string {
  return path.split('/')[5]
}
function extractSourcePortForTakerMsg(path: string) : string{
  return path.split('/')[7]
}

生命周期与控制流程

创建交换

  1. 挂单方在挂单方链上使用指定参数创建订单(见类型 MakeSwap)。挂单方卖出的代币会被发送到模块拥有的托管地址。订单会保存在挂单方链上。
  2. 一条 AtomicSwapPacketData 会被中继到吃单方链,并在 onRecvPacket 中将该订单也保存到吃单方链上。
  3. 随后会中继一个数据包用于确认。若发生数据包超时,或在 onAcknowledgePacket 期间发生失败,则会退还托管中的代币。

接受交换

  1. 吃单方在吃单方链上通过触发 TakeSwap 来接受订单。吃单方卖出的代币会被发送到模块拥有的托管地址。如果当前时间晚于 expirationTimestamp,则该订单不能被接受。
  2. 一条 AtomicSwapPacketData 会被中继到挂单方链,并在 onRecvPacket 中将托管中的代币发送到挂单方链上的吃单方地址。
  3. 随后会中继一个数据包用于确认。确认完成后,吃单方链上托管的代币会被发送到吃单方链上的挂单方地址。若发生数据包超时,或在 onAcknowledgePacket 期间发生失败,则会退还托管中的代币。

取消交换

  1. 挂单方取消一个先前创建的订单。已过期的订单也可以取消。
  2. 一条 AtomicSwapPacketData 会被中继到吃单方链,并在 onRecvPacket 中将吃单方链上的订单取消。如果该订单正处于被接受的过程中(即携带 TakeSwapMsg 的数据包正在从吃单方链中继到挂单方链),则取消操作会被拒绝。
  3. 随后会中继一个数据包返回,确认后挂单方链上的订单也会被取消。只有在吃单方链确认取消请求后,才会执行退款。

子协议

此处描述的子协议应在一个“Fungible Token Swap”模块中实现,该模块需要能够访问银行模块和 IBC 路由模块。
function makeSwap(request: MakeSwapMsg, packet: channelTypes.Packet) {
  const balance = bank.getBalances(request.makerAddress, request.sellToken.denom)
  abortTransactionUnless(balance.amount >= request.sellToken.Amount)
  // gets escrow address by source port and source channel
  const escrowAddr = escrowAddress(request.sourcePort, request.sourceChannel)
  // locks the sellToken to the escrow account
  const err = bank.sendCoins(request.makerAddress, escrowAddr, request.sellToken.amount, request.sellToken.denom)
  abortTransactionUnless(err === null)
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_MAKE_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes
    memo: ""
  }
  sendAtomicSwapPacket(packet, request.sourcePort, request.sourceChannel, request.timeoutHeight, request.timeoutTimestamp)
    
  // creates and saves order on the maker chain.
  const order = createOrder(request, packet)
  // saves order to store
  privateStore.set(order.id, order)
}
function takeSwap(request: TakeSwapMsg) {
  const order = privateStore.get(request.orderId)
  abortTransactionUnless(order !== null)
  abortTransactionUnless(order.expirationTimestamp < currentTimestamp())
  abortTransactionUnless(order.maker.buyToken.denom === request.sellToken.denom)
  abortTransactionUnless(order.maker.buyToken.amount === request.sellToken.amount)
  abortTransactionUnless(order.taker === null)
  // if `desiredTaker` is set, only the desiredTaker can accept the order.
  abortTransactionUnless(order.maker.desiredTaker !== null && order.maker.desiredTaker !== request.takerAddress)
    
  const balance = bank.getBalances(request.takerAddress, request.sellToken.denom)
  abortTransactionUnless(balance.amount >= request.sellToken.amount)
  
  const sourceChannel = extractSourceChannelForTakerMsg(order.path)
  const sourcePort = extractSourcePortForTakerMsg(order.path)
  
  // gets the escrow address by source port and source channel
  const escrowAddr = escrowAddress(sourcePort, sourceChannel)
  // locks the sellToken to the escrow account
  const err = bank.sendCoins(request.takerAddress, escrowAddr, request.sellToken.amount, request.sellToken.denom)
  abortTransactionUnless(err === null)
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_TAKE_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes.
    memo: ""
  } 
    
  sendAtomicSwapPacket(packet, sourcePort, sourceChannel, request.timeoutHeight, request.timeoutTimestamp)
    
  // update order state
  order.taker = request // mark that the order has been occupied
  privateStore.set(order.orderId, order)
}
function cancelSwap(request: CancelSwapMsg) {
  const order = privateStore.get(request.orderId)
  // checks if the order exists
  abortTransactionUnless(order !== null)
  // make sure the sender is the maker of the order.
  abortTransactionUnless(order.maker.makerAddress == request.makerAddress)
  abortTransactionUnless(order.status == Status.SYNC || order.status == Status.INITIAL)
      
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_CANCEL_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes.
    memo: "",
  } 
  // the request is sent to the taker chain, and the taker chain decides if the cancel order is accepted or not
  // the cancellation can only be sent to the same chain as the make order.
  sendAtomicSwapPacket(packet, order.maker.sourcePort, order.maker.sourceChannel request.timeoutHeight, request.timeoutTimestamp)
}

端口与通道设置

链上的同质化代币交换模块必须始终绑定到一个 ID 为 atomicswap 的端口。 在模块创建时(可能是在区块链自身初始化时),必须且只能调用一次 setup 函数,以绑定到相应端口并创建一个由该模块拥有的托管地址。
function setup() {
  capability = routingModule.bindPort("atomicswap", ModuleCallbacks{
    onChanOpenInit,
    onChanOpenTry,
    onChanOpenAck,
    onChanOpenConfirm,
    onChanCloseInit,
    onChanCloseConfirm,
    onRecvPacket,
    onTimeoutPacket,
    onAcknowledgePacket,
    onTimeoutPacketClose
  })
  claimCapability("port", capability)
}
一旦调用了 setup 函数,便可以通过 IBC 路由模块创建通道。

通道生命周期管理

当且仅当满足以下条件时,同质化代币交换模块才会接受来自另一条链上任意模块的新通道:
  • 正在创建的通道是无序通道。
  • 版本字符串为 ics100-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 "ics100-1" or empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics100-1" || version === "")
  
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
  
  return "ics100-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 "ics100-1"
  abortTransactionUnless(counterpartyVersion === "ics100-1")
  
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
  
  return "ics100-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string
) {
  // port has already been validated
  // assert that counterparty selected version is "ics100-1"
  abortTransactionUnless(counterpartyVersion === "ics100-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
}

数据包中继

必须由模块中的事务处理程序调用 sendAtomicSwapPacket,该处理程序会执行适当的签名检查,这些检查应针对宿主状态机上的账户所有者。
function sendAtomicSwapPacket(
  swapPacket: AtomicSwapPacketData, 
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64
) {
  // send packet using the interface defined in ICS4
  handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    protobuf.marshal(swapPacket) // protobuf-marshalled bytes of packet data
  )
}
当发送到该模块的数据包已被接收时,路由模块会调用 onRecvPacket。
function onRecvPacket(packet channeltypes.Packet) {
  const swapPacket: AtomicSwapPacketData = packet.data
  
  AtomicSwapPacketAcknowledgement ack = AtomicSwapPacketAcknowledgement{true, null}
  
  switch swapPacket.type {
    case TYPE_MSG_MAKE_SWAP:
      const makeMsg = protobuf.decode(swapPacket.data)
        
      // check if buyToken is a valid token on the taker chain, could be either native or ibc token
      const supply = bank.getSupply(makeMsg.buyToken.denom)
      if (supply <= 0) {
        ack = AtomicSwapPacketAcknowledgement{false, "sell token does not exist on the taker chain"}
        break;
      }
        
      // create and save order on the taker chain.
      const order = createOrder(makeMsg, packet)
      order.status = Status.SYNC
      // saves order to store
      const err = privateStore.set(order.orderId, order)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "failed to save the order on taker chain"}
      }
      break;
    case TYPE_MSG_TAKE_SWAP:
      const takeMsg = protobuf.decode(swapPacket.data)
      const order = privateStore.get(takeMsg.orderId)
      abortTransactionUnless(order !== null)
      abortTransactionUnless(order.status === Status.SYNC) 
      abortTransactionUnless(order.expiredTimestamp < currentTimestamp())
      abortTransactionUnless(takeMsg.sellToken.denom === order.maker.buyToken.denom)
      abortTransactionUnless(takeMsg.sellToken.amount === order.maker.buyToken.amount)
      // if `desiredTaker` is set, only the desiredTaker can accept the order.
      abortTransactionUnless(order.maker.desiredTaker !== null && order.maker.desiredTaker !== takeMsg.takerAddress)
  
      const escrowAddr = escrowAddress(order.maker.sourcePort, order.maker.sourceChannel)
      // send maker.sellToken to taker's receiving address
      const err = bank.sendCoins(escrowAddr, takeMsg.takerReceivingAddress, order.maker.sellToken.amount, order.maker.sellToken.denom)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "transfer coins failed"}
        break;
      }
        
      // update status of order
      order.status = Status.COMPLETE
      order.taker = takeMsg
      order.completeTimestamp = takeMsg.creationTimestamp
      privateStore.set(takeMsg.orderId, order)
      break;
    case TYPE_MSG_CANCEL_SWAP:
      const cancelMsg = protobuf.decode(swapPacket.data)
      const order = privateStore.get(cancelMsg.orderId)
      abortTransactionUnless(order !== null)
      abortTransactionUnless(order.status === Status.SYNC || order.status == Status.INITIAL)
      abortTransactionUnless(order.taker !== null) // the maker order has not been occupied 
        
      // update status of order
      order.status = Status.CANCEL
      order.cancelTimestamp = cancelMsg.creationTimestamp 
      const err = privateStore.set(cancelMsg.orderId, order)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "failed to cancel order on taker chain"}
      }
      break;
    default:
      ack = AtomicSwapPacketAcknowledgement{false, "unknown data packet"} 
  }
  
  return ack
}
当该模块发送的数据包已被确认时,路由模块会调用 onAcknowledgePacket。
function onAcknowledgePacket(
  packet: channeltypes.Packet
  acknowledgement: bytes) {
  // ack is failed
  if (!acknowledgement.success) {
    refundTokens(packet) 
  } else {
    
    const swapPacket: AtomicSwapPacketData = protobuf.decode(packet.data)
    const escrowAddr = escrowAddress(packet.sourcePort, packet.sourceChannel)
    
    switch swapPacket.type {
      case TYPE_MSG_MAKE_SWAP:
        const makeMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the maker chain.
        const order = privateStore.get(generateOrderId(packet))
        order.status = Status.SYNC
        // save order to store
        privateStore.set(order.id, order)
        break;
      case TYPE_MSG_TAKE_SWAP:
        const takeMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the taker chain.
        const order = privateStore.get(takeMsg.orderId)
        
        // send tokens to maker
        bank.sendCoins(escrowAddr, order.maker.makerReceivingAddress, takeMsg.sellToken.amount, takeMsg.sellToken.denom)
        
        order.status = Status.COMPLETE
        order.taker = takeMsg
        order.completeTimestamp = takeMsg.creationTimestamp
        privateStore.set(order.id, order)
        
        break;
      case TYPE_MSG_CANCEL_SWAP:
        const cancelMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the maker chain.
        const order = privateStore.get( cancelMsg.orderId )
        
        // send tokens back to maker
        bank.sendCoins(escrowAddr, order.maker.makerAddress, order.maker.sellToken.amount, order.maker.sellToken.denom)
        
        // update state on maker chain
        order.status = Status.CANCEL
        order.cancelTimestamp = cancelMsg.creationTimestamp
        privateStore.set(order.id, order)
        
        break;
      default:
        throw new Error("ErrUnknownDataPacket")
    }
  }
}
当该模块发送的数据包超时后,路由模块会调用 onTimeoutPacket(此时这些代币将被退款)。
function onTimeoutPacket(packet: Packet) {
  // the packet timed-out, so refund the tokens
  refundTokens(packet)
}
refundTokens 会在 onAcknowledgePacket 失败时以及 onTimeoutPacket 中被调用,用于将托管的代币退还给原始所有者。
function refundTokens(packet: Packet) {
  const swapPacket: AtomicSwapPacketData = protobuf.decode(packet.data)
  const escrowAddr = escrowAddress(packet.sourcePort, packet.sourceChannel)
  
  // send tokens from module to message sender
  switch swapPacket.type {
    case TYPE_MSG_MAKE_SWAP:
      const msg = protobuf.decode(swapPacket.data)
      bank.sendCoins(escrowAddr, msg.makerAddress, msg.sellToken.amount, msg.sellToken.denom)
      const orderId = generateOrderId(packet)
      const order = privateStore.get(order.orderId)
      order.status = Status.CANCEL
      privateStore.set(order.orderId, order)
      break;
    case TYPE_MSG_TAKE_SWAP:
      const msg = protobuf.decode(swapPacket.data)
      bank.sendCoins(escrowAddr, msg.takerAddress, msg.sellToken.amount, msg.sellToken.denom)
      const order = privateStore.get(msg.orderId)
      order.taker = null // release the occupation
      privateStore.set(order.orderId, order)
      break;
    case TYPE_MSG_CANCEL_SWAP:
      // do nothing, only send tokens back when cancel msg is acknowledged.
  }
}
function onTimeoutPacketClose(packet: AtomicSwapPacketData) {
  // can't happen, only unordered channels allowed
}

向后兼容性

不适用。

向前兼容性

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

示例实现

历史

2022 年 8 月 15 日 - 草案编写 2022 年 10 月 6 日 - 草案修订 2022 年 11 月 11 日 - 草案修订

版权

此处的所有内容均依据 Apache 2.0 许可。
This specification has been reviewed by the Spec Committee, but it is not responsible for maintaining it.

Synopsis

This standard document specifies packet data structure, state machine handling logic, and encoding details for the atomic swap of fungible tokens over an IBC channel between two modules on separate chains.

Motivation

Users may wish to exchange tokens without transferring tokens away from their native chain. ICS-100 enabled chains can facilitate atomic swaps between users and their tokens located on the different chains. This is useful for exchanges between specific users at specific prices, and opens opportunities for new application designs. For example, a token exchange would require only one transaction from a user, compared to multiple transactions when using ICS-20. Additionally, users can minimize trade slippage compared to using a liquidity pool, given there is a willing counter-party.

Definitions

Atomic Swap: An exchange of tokens from separate chains without transferring tokens from one blockchain to another. The exchange either happens or it doesn’t — there is no other alternative. Order: An offer to exchange quantity X of token A for quantity Y of token B. Tokens offered are sent to an escrow account (owned by the module). Maker: A user that makes or initiates an order. Taker: The counterparty who takes or responds to an order. Maker Chain: The blockchain where a maker makes or initiates an order. Taker Chain: The blockchain where a taker takes or responds to an order.

Desired Properties

  • Permissionless: no need to whitelist connections, modules, or denominations.
  • Guarantee of exchange: no occurrence of a user receiving tokens without the equivalent promised exchange.
  • Escrow enabled: an account owned by the module will hold tokens and facilitate exchange.
  • Refundable: tokens are refunded by escrow when a timeout occurs, or when an order is cancelled.
  • Order cancellation: orders without takers can be cancelled.
  • Basic orderbook: a store of orders functioning as an orderbook system.
  • Atomicity: an exchange of one token for another where it is either a total success or a total failure.

Technical Specification

General Design

IBC swap diagram A maker offers token A in exchange for token B by making an order. The order specifies the quantity and price of exchange, and sends the offered token A to the maker chain’s escrow account. Any taker on a different chain with token B can accept the offer by taking the order. The taker sends the desired amount of token B to the taker chain’s escrow account. The escrow account on each respective chain transfers the corresponding token amounts to each user’s receiving address, without requiring the usual IBC transfer. An order without takers can be cancelled. This enables users to rectify mistakes, such as inputting an incorrect price or taker address. Upon cancellation escrowed tokens will be refunded. When making or taking an order, a timeout window is specified in the relayed data packet. A timeout will result in escrowed tokens refunded back. This timeout window is customizable.

Data Structures

Only one packet data type is required: AtomicSwapPacketData, which specifies the swap message type, data (protobuf marshalled) and a memo field.
enum SwapMessageType {
  // Default zero value enumeration
  TYPE_UNSPECIFIED = 0,
  TYPE_MSG_MAKE_SWAP = 1,
  TYPE_MSG_TAKE_SWAP = 2,
  TYPE_MSG_CANCEL_SWAP = 3,
}
// AtomicSwapPacketData is comprised of a swap message type, raw transaction and optional memo field.
interface AtomicSwapPacketData {
  type: SwapMessageType
  data: bytes
  memo: string
}
type AtomicSwapPacketAcknowledgement = AtomicSwapPacketSuccess | AtomicSwapPacketError
interface AtomicSwapPacketSuccess {
  // This is binary 0x01 base64 encoded
  result: "AQ=="
}

interface AtomicSwapPacketError {
  error: string
}
All AtomicSwapPacketData will be forwarded to the corresponding message handler to execute according to its type. There are 3 types:
interface MakeSwapMsg {
  // the port on which the packet will be sent, specified by the maker when the message is created
  sourcePort: string
  // the channel on which the packet will be sent, specified by the maker when the message is created
  sourceChannel: string
  // the tokens to be exchanged
  sellToken: Coin
  buyToken: Coin
  // the maker's address
  makerAddress: string
  // the maker's address on the taker chain
  makerReceivingAddress string
  // if desiredTaker is specified,
  // only the desiredTaker is allowed to take this order
  // this is the address on the taker chain
  desiredTaker: string
  creationTimestamp: uint64
  expirationTimestamp: uint64
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
interface TakeSwapMsg {
  orderId: string
  // the tokens to be sold
  sellToken: Coin
  // the taker's address
  takerAddress: string
  // the taker's address on the maker chain
  takerReceivingAddress: string
  creationTimestamp: uint64
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
interface CancelSwapMsg {
  orderId: string
  makerAddress: string
  timeoutHeight: Height
  timeoutTimestamp: uint64
}
where Coin is an interface that consists of an amount and a denomination:
interface Coin {
  amount: int64
  denom: string
}
Both the maker chain and taker chain maintain separate order store. Orders are saved in both maker chain and taker chain.
// Status of order 
enum Status {
  INITIAL = 0, // initialed on maker chain
  SYNC = 1, // synced to the taker chain
  CANCEL = 2, // canceled 
  COMPLETE = 3, // completed
}

interface Order {
  id: string
  maker: MakeSwapMsg
  status: Status
  // an IBC path, define channel and port on both Maker Chain and Taker Chain 
  path: string
  taker: TakeSwapMsg
  cancelTimestamp: uint64
  completeTimestamp: uint64
}

function createOrder(msg: MakeSwapMsg, packet: channelType.Packet): Order {
    const path = orderPath(packet)
    return Order{
      id : generateOrderId(packet),
      status: Status.INITIAL,
      path: path,
      maker: msg,
    }
  }

function orderPath(packet: channelType.Packet): string {
    return `channel/${packet.sourceChannel}/port/${packet.sourcePort}/channel/${packet.destChannel}/port/${packet.destPort}/sequence/${packet.sequence}`

}
// Order id is a global unique string, since packet contains sourceChannel, SourcePort, distChannel, distPort, sequence and msg data
function generateOrderId(packet: channelType.Packet) : string {
  const bytes = protobuf.encode(packet)
  return sha265(bytes)
}

function extractSourceChannelForTakerMsg(path: string) : string {
  return path.split('/')[5]
}
function extractSourcePortForTakerMsg(path: string) : string{
  return path.split('/')[7]
}

Life scope and control flow

Making a swap

  1. A maker creates an order on the maker chain with specified parameters (see type MakeSwap). The maker’s sell tokens are sent to the escrow address owned by the module. The order is saved on the maker chain.
  2. An AtomicSwapPacketData is relayed to the taker chain where in onRecvPacket the order is also saved on the taker chain.
  3. A packet is subsequently relayed back for acknowledgement. A packet timeout or a failure during onAcknowledgePacket will result in a refund of the escrowed tokens.

Taking a swap

  1. A taker takes an order on the taker chain by triggering TakeSwap. The taker’s sell tokens are sent to the escrow address owned by the module. An order cannot be taken if the current time is later than the expirationTimestamp.
  2. An AtomicSwapPacketData is relayed to the maker chain where in onRecvPacket the escrowed tokens are sent to the taker address on the maker chain.
  3. A packet is subsequently relayed back for acknowledgement. Upon acknowledgement escrowed tokens on the taker chain are sent to the maker address on the taker chain. A packet timeout or a failure during onAcknowledgePacket will result in a refund of the escrowed tokens.

Cancelling a swap

  1. A maker cancels a previously created order. Expired orders can also be cancelled.
  2. An AtomicSwapPacketData is relayed to the taker chain where in onRecvPacket the order is cancelled on the taker chain. If the order is in the process of being taken (a packet with TakeSwapMsg is being relayed from the taker chain to the maker chain), the cancellation will be rejected.
  3. A packet is relayed back where upon acknowledgement the order on the maker chain is also cancelled. The refund only occurs if the taker chain confirmed the cancellation request.

Sub-protocols

The sub-protocols described herein should be implemented in a “Fungible Token Swap” module with access to a bank module and to the IBC routing module.
function makeSwap(request: MakeSwapMsg, packet: channelTypes.Packet) {
  const balance = bank.getBalances(request.makerAddress, request.sellToken.denom)
  abortTransactionUnless(balance.amount >= request.sellToken.Amount)
  // gets escrow address by source port and source channel
  const escrowAddr = escrowAddress(request.sourcePort, request.sourceChannel)
  // locks the sellToken to the escrow account
  const err = bank.sendCoins(request.makerAddress, escrowAddr, request.sellToken.amount, request.sellToken.denom)
  abortTransactionUnless(err === null)
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_MAKE_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes
    memo: ""
  }
  sendAtomicSwapPacket(packet, request.sourcePort, request.sourceChannel, request.timeoutHeight, request.timeoutTimestamp)
    
  // creates and saves order on the maker chain.
  const order = createOrder(request, packet)
  // saves order to store
  privateStore.set(order.id, order)
}
function takeSwap(request: TakeSwapMsg) {
  const order = privateStore.get(request.orderId)
  abortTransactionUnless(order !== null)
  abortTransactionUnless(order.expirationTimestamp < currentTimestamp())
  abortTransactionUnless(order.maker.buyToken.denom === request.sellToken.denom)
  abortTransactionUnless(order.maker.buyToken.amount === request.sellToken.amount)
  abortTransactionUnless(order.taker === null)
  // if `desiredTaker` is set, only the desiredTaker can accept the order.
  abortTransactionUnless(order.maker.desiredTaker !== null && order.maker.desiredTaker !== request.takerAddress)
    
  const balance = bank.getBalances(request.takerAddress, request.sellToken.denom)
  abortTransactionUnless(balance.amount >= request.sellToken.amount)
  
  const sourceChannel = extractSourceChannelForTakerMsg(order.path)
  const sourcePort = extractSourcePortForTakerMsg(order.path)
  
  // gets the escrow address by source port and source channel
  const escrowAddr = escrowAddress(sourcePort, sourceChannel)
  // locks the sellToken to the escrow account
  const err = bank.sendCoins(request.takerAddress, escrowAddr, request.sellToken.amount, request.sellToken.denom)
  abortTransactionUnless(err === null)
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_TAKE_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes.
    memo: ""
  } 
    
  sendAtomicSwapPacket(packet, sourcePort, sourceChannel, request.timeoutHeight, request.timeoutTimestamp)
    
  // update order state
  order.taker = request // mark that the order has been occupied
  privateStore.set(order.orderId, order)
}
function cancelSwap(request: CancelSwapMsg) {
  const order = privateStore.get(request.orderId)
  // checks if the order exists
  abortTransactionUnless(order !== null)
  // make sure the sender is the maker of the order.
  abortTransactionUnless(order.maker.makerAddress == request.makerAddress)
  abortTransactionUnless(order.status == Status.SYNC || order.status == Status.INITIAL)
      
  // constructs the IBC data packet
  const packet = {
    type: SwapMessageType.TYPE_MSG_CANCEL_SWAP,
    data: protobuf.encode(request), // encode the request message to protobuf bytes.
    memo: "",
  } 
  // the request is sent to the taker chain, and the taker chain decides if the cancel order is accepted or not
  // the cancellation can only be sent to the same chain as the make order.
  sendAtomicSwapPacket(packet, order.maker.sourcePort, order.maker.sourceChannel request.timeoutHeight, request.timeoutTimestamp)
}

Port & channel setup

The fungible token swap module on a chain must always bind to a port with the id atomicswap. 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("atomicswap", 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 via the IBC routing module.

Channel lifecycle management

An fungible token swap module will accept new channels from any module on another machine, if and only if:
  • The channel being created is unordered.
  • The version string is ics100-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 "ics100-1" or empty
  // if empty, we return the default transfer version to core IBC
  // as the version for this channel
  abortTransactionUnless(version === "ics100-1" || version === "")
  
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
  
  return "ics100-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 "ics100-1"
  abortTransactionUnless(counterpartyVersion === "ics100-1")
  
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
  
  return "ics100-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string
) {
  // port has already been validated
  // assert that counterparty selected version is "ics100-1"
  abortTransactionUnless(counterpartyVersion === "ics100-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

sendAtomicSwapPacket 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 sendAtomicSwapPacket(
  swapPacket: AtomicSwapPacketData, 
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64
) {
  // send packet using the interface defined in ICS4
  handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    protobuf.marshal(swapPacket) // protobuf-marshalled bytes of packet data
  )
}
onRecvPacket is called by the routing module when a packet addressed to this module has been received.
function onRecvPacket(packet channeltypes.Packet) {
  const swapPacket: AtomicSwapPacketData = packet.data
  
  AtomicSwapPacketAcknowledgement ack = AtomicSwapPacketAcknowledgement{true, null}
  
  switch swapPacket.type {
    case TYPE_MSG_MAKE_SWAP:
      const makeMsg = protobuf.decode(swapPacket.data)
        
      // check if buyToken is a valid token on the taker chain, could be either native or ibc token
      const supply = bank.getSupply(makeMsg.buyToken.denom)
      if (supply <= 0) {
        ack = AtomicSwapPacketAcknowledgement{false, "sell token does not exist on the taker chain"}
        break;
      }
        
      // create and save order on the taker chain.
      const order = createOrder(makeMsg, packet)
      order.status = Status.SYNC
      // saves order to store
      const err = privateStore.set(order.orderId, order)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "failed to save the order on taker chain"}
      }
      break;
    case TYPE_MSG_TAKE_SWAP:
      const takeMsg = protobuf.decode(swapPacket.data)
      const order = privateStore.get(takeMsg.orderId)
      abortTransactionUnless(order !== null)
      abortTransactionUnless(order.status === Status.SYNC) 
      abortTransactionUnless(order.expiredTimestamp < currentTimestamp())
      abortTransactionUnless(takeMsg.sellToken.denom === order.maker.buyToken.denom)
      abortTransactionUnless(takeMsg.sellToken.amount === order.maker.buyToken.amount)
      // if `desiredTaker` is set, only the desiredTaker can accept the order.
      abortTransactionUnless(order.maker.desiredTaker !== null && order.maker.desiredTaker !== takeMsg.takerAddress)
  
      const escrowAddr = escrowAddress(order.maker.sourcePort, order.maker.sourceChannel)
      // send maker.sellToken to taker's receiving address
      const err = bank.sendCoins(escrowAddr, takeMsg.takerReceivingAddress, order.maker.sellToken.amount, order.maker.sellToken.denom)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "transfer coins failed"}
        break;
      }
        
      // update status of order
      order.status = Status.COMPLETE
      order.taker = takeMsg
      order.completeTimestamp = takeMsg.creationTimestamp
      privateStore.set(takeMsg.orderId, order)
      break;
    case TYPE_MSG_CANCEL_SWAP:
      const cancelMsg = protobuf.decode(swapPacket.data)
      const order = privateStore.get(cancelMsg.orderId)
      abortTransactionUnless(order !== null)
      abortTransactionUnless(order.status === Status.SYNC || order.status == Status.INITIAL)
      abortTransactionUnless(order.taker !== null) // the maker order has not been occupied 
        
      // update status of order
      order.status = Status.CANCEL
      order.cancelTimestamp = cancelMsg.creationTimestamp 
      const err = privateStore.set(cancelMsg.orderId, order)
      if (err != null) {
        ack = AtomicSwapPacketAcknowledgement{false, "failed to cancel order on taker chain"}
      }
      break;
    default:
      ack = AtomicSwapPacketAcknowledgement{false, "unknown data packet"} 
  }
  
  return ack
}
onAcknowledgePacket is called by the routing module when a packet sent by this module has been acknowledged.
function onAcknowledgePacket(
  packet: channeltypes.Packet
  acknowledgement: bytes) {
  // ack is failed
  if (!acknowledgement.success) {
    refundTokens(packet) 
  } else {
    
    const swapPacket: AtomicSwapPacketData = protobuf.decode(packet.data)
    const escrowAddr = escrowAddress(packet.sourcePort, packet.sourceChannel)
    
    switch swapPacket.type {
      case TYPE_MSG_MAKE_SWAP:
        const makeMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the maker chain.
        const order = privateStore.get(generateOrderId(packet))
        order.status = Status.SYNC
        // save order to store
        privateStore.set(order.id, order)
        break;
      case TYPE_MSG_TAKE_SWAP:
        const takeMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the taker chain.
        const order = privateStore.get(takeMsg.orderId)
        
        // send tokens to maker
        bank.sendCoins(escrowAddr, order.maker.makerReceivingAddress, takeMsg.sellToken.amount, takeMsg.sellToken.denom)
        
        order.status = Status.COMPLETE
        order.taker = takeMsg
        order.completeTimestamp = takeMsg.creationTimestamp
        privateStore.set(order.id, order)
        
        break;
      case TYPE_MSG_CANCEL_SWAP:
        const cancelMsg = protobuf.decode(swapPacket.data)
        
        // update order status on the maker chain.
        const order = privateStore.get( cancelMsg.orderId )
        
        // send tokens back to maker
        bank.sendCoins(escrowAddr, order.maker.makerAddress, order.maker.sellToken.amount, order.maker.sellToken.denom)
        
        // update state on maker chain
        order.status = Status.CANCEL
        order.cancelTimestamp = cancelMsg.creationTimestamp
        privateStore.set(order.id, order)
        
        break;
      default:
        throw new Error("ErrUnknownDataPacket")
    }
  }
}
onTimeoutPacket is called by the routing module when a packet sent by this module has timed-out (such that the tokens will be refunded).
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 owner.
function refundTokens(packet: Packet) {
  const swapPacket: AtomicSwapPacketData = protobuf.decode(packet.data)
  const escrowAddr = escrowAddress(packet.sourcePort, packet.sourceChannel)
  
  // send tokens from module to message sender
  switch swapPacket.type {
    case TYPE_MSG_MAKE_SWAP:
      const msg = protobuf.decode(swapPacket.data)
      bank.sendCoins(escrowAddr, msg.makerAddress, msg.sellToken.amount, msg.sellToken.denom)
      const orderId = generateOrderId(packet)
      const order = privateStore.get(order.orderId)
      order.status = Status.CANCEL
      privateStore.set(order.orderId, order)
      break;
    case TYPE_MSG_TAKE_SWAP:
      const msg = protobuf.decode(swapPacket.data)
      bank.sendCoins(escrowAddr, msg.takerAddress, msg.sellToken.amount, msg.sellToken.denom)
      const order = privateStore.get(msg.orderId)
      order.taker = null // release the occupation
      privateStore.set(order.orderId, order)
      break;
    case TYPE_MSG_CANCEL_SWAP:
      // do nothing, only send tokens back when cancel msg is acknowledged.
  }
}
function onTimeoutPacketClose(packet: AtomicSwapPacketData) {
  // can't happen, only unordered channels allowed
}

Backwards Compatibility

Not applicable.

Forwards Compatibility

This initial standard uses version “ics100-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

Aug 15, 2022 - Draft written Oct 6, 2022 - Draft revised Nov 11, 2022 - Draft revised All content herein is licensed under Apache 2.0.