本标准文档遵循与 ICS 20 相同的设计原则,并继承了其中的大部分内容,同时将基于 bank 模块的资产跟踪逻辑替换为 nft 模块的逻辑。

概述

本标准文档规定了在两条独立链上的两个模块之间,通过 IBC 通道传输非同质化代币时所使用的数据包数据结构、状态机处理逻辑以及编码细节。在本文档中,class、collection 和 contract 可互换使用。本文给出的状态机逻辑允许在无需许可的通道开启前提下,安全地处理多链 classId。该逻辑构成了一个非同质化代币传输桥接模块,用于在 IBC 路由模块与宿主状态机中现有的资产跟踪模块之间进行交互;该资产跟踪模块既可以是 Cosmos 风格的原生模块,也可以是运行在虚拟机中的智能合约。

动机

一组通过 IBC 协议连接的链的用户,可能希望在某条并非该代币原始发行链的链上使用某个非同质化代币,例如为了利用额外功能,如交易、版税支付或隐私保护。本应用层标准描述了一种在通过 IBC 连接的链之间传输非同质化代币的协议,该协议能够保持资产的非同质性,保持资产所有权,限制拜占庭故障的影响,并且不需要额外许可。

定义

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

期望属性

  • 保持非同质性(即任意代币在所有通过 IBC 连接的区块链中,任一时刻都只有一个实例处于有效状态)。
  • 无需许可的代币传输,无需将连接、模块或 classId 加入白名单。
  • 对称性(所有链实现相同逻辑,协议内不区分枢纽链与区域链)。
  • 故障隔离:防止由于链 B 的拜占庭行为而伪造出源自链 A 的代币。

技术规范

数据结构

只需要一种数据包数据类型:NonFungibleTokenPacketData,它指定类 ID、类 URI、类数据、代币 ID 数组、代币 URI 数组、代币数据数组、发送地址和接收地址。
interface NonFungibleTokenPacketData {
  classId: string
  classUri: string
  classData: string
  tokenIds: string[]
  tokenUris: string[]
  tokenData: string[]
  sender: string
  receiver: string
  memo: string
}
classId 是必填字段,绝不能为空。它在发送链上唯一标识所传输代币所属的类/集合/合约。例如,在符合 ERC-1155 的智能合约场景中,它可以是代币 ID 高 128 位的字符串表示。 classUri 是可选字段;如果提供,则必须非空,并指向一个链下资源,通常是包含类元数据的 JSON 文件;这对于与 OpenSea 等 NFT 市场进行跨链互操作会非常有帮助。 classData 是可选字段;如果提供,则必须非空,并包含链上的类元数据,例如与版税相关的参数。 tokenIds 数组是必填字段,其大小必须大于零,且其中每个条目都必须非空,用于唯一标识正在传输的代币(属于给定类)。例如,在符合 ERC-1155 的智能合约场景中,tokenId 可以是代币 ID 低 128 位的字符串表示。 tokenUris 数组是可选字段;如果提供,则其大小必须与 tokenIds 相同,且其中每个条目都必须非空,并分别指向一个链下资源,通常是一个不可变的 JSON 文件,其中包含与对应 tokenIds 条目标识的代币相关联的元数据。 tokenData 数组是可选字段;如果提供,则其大小必须与 tokenIds 相同,且其中每个条目都必须非空,并包含与对应 tokenIds 条目标识的代币相关联的链上应用数据。 tokenData 中的条目以及 classData 都必须是 Base64 编码字符串,并且其 JSON 结构应当如下:
{
  "key1" : { "value":"...", "mime":"..." },
  "key2" : { "value":"...", "mime":"..." },
  ...
}
mime 是一个可选属性,用于指定对应键值的媒体类型。如果某个键值的默认类型是字符串,则可以省略 mime。否则,mime 必须非空,并且其值必须来自这个列表。 建议链上应用对这些键进行命名空间划分;为了在不同应用之间实现最大的互操作性,理想情况下应对这些命名空间进行标准化,但这不在本文档讨论范围之内。 下面展示了一个 classData 内容示例(Base64 编码前的原始 JSON):
{
  "opensea:name" : { "value":"Crypto Creatures" },
  "opensea:image" : { "value":"...(Base64 encoded media binary)", "mime":"image/png" },
  "opensea:seller_fee_basis_points" : { "value":"100" }
}
可选的 memo 字段在传输过程中本身不会被使用,但它既可供外部链下用户(例如交易所)使用,也可供包装传输逻辑的中间件使用,后者可以基于传入的 memo 解析并执行自定义逻辑。如果该 memo 计划由更高层中间件解析和解释,建议这些中间件对其添加内容使用各自的命名空间,以避免相互覆盖。各链应确保对整个数据包数据设置某种长度限制,以防止数据包成为 DOS 攻击载体。不过,这些限制不必由协议统一定义。如果接收方由于长度限制无法接受某个数据包,这将导致发送方一侧发生超时。 随着代币通过 ICS-721 协议在链之间传输,它们会逐步累积一份曾经经过的通道记录。该记录信息被编码在 classId 字段中。 ICS-721 代币类采用 {ics721Port}/{ics721Channel}/{classId} 的形式表示,其中 ics721Port 和 ics721Channel 标识当前链上该代币到达时所经过的通道。如果 {classId} 中包含 /,那么它本身也必须是 ICS-721 形式,这表明该代币具有多跳传输记录。请注意,这要求非 IBC 代币的 classId 中禁止出现 /(斜杠字符)。 发送链可以充当源区或汇区。当一条链通过某个端口和通道发送代币,而该端口和通道不等于最后一个前缀中的端口和通道对时,它充当源区。当代币从源区发送时,目标端口和通道会在代币被接收后追加到 classId 前缀中,从而为该代币的记录增加一跳。当一条链通过某个端口和通道发送代币,而该端口和通道等于最后一个前缀中的端口和通道对时,它充当汇区。当代币从汇区发送时,classId 上最后一个前缀的端口和通道对会在代币被接收后移除,从而撤销该代币记录中的最后一跳。 例如,假设发生了如下传输步骤: A -> B -> C -> A -> C -> B -> A
  1. A(p1,c1) -> (p2,c2)B:A 是源区。B 中的 classId:p2/c2/nftClass
  2. B(p3,c3) -> (p4,c4)C:B 是源区。C 中的 classId:p4/c4/p2/c2/nftClass
  3. C(p5,c5) -> (p6,c6)A:C 是源区。A 中的 classId:p6/c6/p4/c4/p2/c2/nftClass
  4. A(p6,c6) -> (p5,c5)C:A 是汇区。C 中的 classId:p4/c4/p2/c2/nftClass
  5. C(p4,c4) -> (p3,c3)B:C 是汇区。B 中的 classId:p2/c2/nftClass
  6. B(p2,c2) -> (p1,c1)A:B 是汇区。A 中的 classId:nftClass
确认数据类型用于描述传输成功还是失败,以及失败原因(如果有)。
type NonFungibleTokenPacketAcknowledgement =
  | NonFungibleTokenPacketSuccess
  | NonFungibleTokenPacketError

interface NonFungibleTokenPacketSuccess {
  // This is binary 0x01 base64 encoded
  success: "AQ=="
}

interface NonFungibleTokenPacketError {
  error: string
}
请注意,NonFungibleTokenPacketData 和 NonFungibleTokenPacketAcknowledgement 在序列化为数据包数据时,都必须采用 JSON 编码,而不是 Protobuf 编码。 非同质化代币传输桥接模块会为每个 NFT 通道维护一个独立的托管地址。
interface ModuleState {
  channelEscrowAddresses: Map<Identifier, string>
}

子协议

此处描述的子协议应由一个“非同质化代币传输桥接”模块实现,该模块需要能够访问 NFT 资产跟踪模块和 IBC 路由模块。 NFT 资产跟踪模块应实现以下函数:
function CreateOrUpdateClass(classId: string, classUri: string, classData: string) {
  // creates a new NFT Class identified by classId
  // if classId already exists, app logic may choose to update class metadata accordingly
}
function Mint(classId: string, tokenId: string, tokenUri: string, tokenData: string, receiver: string) {
  // creates a new NFT identified by <classId,tokenId>
  // receiver becomes owner of the newly minted NFT
}
function Transfer(classId: string, tokenId: string, receiver: string, tokenData: string) {
  // transfers the NFT identified by <classId,tokenId> to receiver
  // receiver becomes new owner of the NFT
  // if tokenData is not empty, app logic may choose to update token data accordingly
}
function Burn(classId: string, tokenId: string) {
  // destroys the NFT identified by <classId,tokenId>
}
function GetOwner(classId: string, tokenId: string) {
  // returns current owner of the NFT identified by <classId,tokenId>
}
function GetNFT(classId: string, tokenId: string) {
  // returns NFT identified by <classId,tokenId>
}
function GetClass(classId: string) {
  // returns NFT Class identified by classId
}

端口与通道设置

必须在模块创建时恰好调用一次 setup 函数(例如在区块链自身初始化时),以绑定到适当的端口(该端口由模块拥有)。
function setup() {
  capability = routingModule.bindPort("nft", ModuleCallbacks{
    onChanOpenInit,
    onChanOpenTry,
    onChanOpenAck,
    onChanOpenConfirm,
    onChanCloseInit,
    onChanCloseConfirm,
    onRecvPacket,
    onTimeoutPacket,
    onAcknowledgePacket,
    onTimeoutPacketClose
  })
  claimCapability("port", capability)
}
一旦调用了 setup 函数,就可以通过 IBC 路由模块,在不同链上的非同质化代币传输模块实例之间创建通道。 本规范只定义数据包处理语义,并且其定义方式使得模块本身无需关心任意时刻可能存在或不存在的连接或通道。

路由模块回调

通道生命周期管理
当且仅当满足以下条件时,状态机 A 和 B 才会接受来自另一台状态机上任意模块的新通道:
  • 正在创建的通道是无序通道。
  • 版本字符串为 ics721-1。
function onChanOpenInit(
  order: ChannelOrder,
  connectionHops: Identifier[],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  version: string): (version: string, err: Error) {
  // 仅允许无序通道
  abortTransactionUnless(order === UNORDERED)
  // 断言版本为 "ics721-1"
  // 或中继者传入了空版本
  abortTransactionUnless(version === "ics721-1" || version === "")
  return "ics721-1", nil
}
function onChanOpenTry(
  order: ChannelOrder,
  connectionHops: Identifier[],
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyPortIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string): (version: string, err: Error) {
  // 仅允许无序通道
  abortTransactionUnless(order === UNORDERED)
  // 断言版本为 "ics721-1"
  abortTransactionUnless(counterpartyVersion === "ics721-1")
  return "ics721-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string
) {
  // 端口已完成校验
  // 断言版本为 "ics721-1"
  abortTransactionUnless(counterpartyVersion === "ics721-1")
  // 分配一个托管地址
  channelEscrowAddresses[channelIdentifier] = newAddress()
}
function onChanOpenConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // 接受通道确认,端口已完成校验,版本也已完成校验
  // 分配一个托管地址
  channelEscrowAddresses[channelIdentifier] = newAddress()
}
function onChanCloseInit(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // 中止并返回错误,以阻止用户关闭通道
  abortTransactionUnless(FALSE)
}
function onChanCloseConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // 无需操作
}
数据包中继
  • 当非同质化代币从其源链发送出去时,桥接模块会在发送链上托管该代币,并在接收链上铸造相应的凭证。
  • 当非同质化代币朝其源链方向发送回来时,桥接模块会在发送链上销毁该代币,并在接收链上解除托管对应的锁定代币。
  • 当数据包超时时,数据包中表示的代币会根据代币是正从源链移出还是正返回源链,被适当地解除托管或重新铸造回发送者。
  • 确认数据用于处理失败情况,例如无效的目标账户。相较于中止交易,返回失败确认更可取,因为这样更容易让发送链根据失败性质采取适当行动。
createOutgoingPacket 必须由该模块中的交易处理器调用,并由其执行适当的签名检查,这些检查针对宿主状态机上的账户所有者。
function createOutgoingPacket(
  classId: string,
  tokenIds: string[],
  sender: string,
  receiver: string,
  destPort: string,
  destChannel: string,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64): uint64 {
  prefix = sourcePort + '/' + sourceChannel
  // 如果 classId 没有以 sourcePort 和 sourceChannel 为前缀,则我们是源链
  source = classId.slice(0, len(prefix)) !== prefix
  tokenUris = []
  tokenData = []
  for (let tokenId in tokenIds) {
    // 确保 sender 是代币所有者
    abortTransactionUnless(sender === nft.GetOwner(classId, tokenId))
    if source { // 我们是源链,托管代币
      nft.Transfer(classId, tokenId, channelEscrowAddresses[sourceChannel], null)
    } else { // 我们是汇链,销毁凭证
      nft.Burn(classId, tokenId)
    }
    token = nft.GetNFT(classId, tokenId)
    tokenUris.push(token.GetUri())
    tokenData.push(token.GetData())
  }
  NonFungibleTokenPacketData data = NonFungibleTokenPacketData{
    classId,
    nft.GetClass(classId).GetUri(),
    nft.GetClass(classId).GetData(),
    tokenIds,
    tokenUris,
    tokenData,
    sender,
    receive
  }
  sequence = Handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    protobuf.marshal(data) // protobuf 编码后的数据包数据字节
  )
  return sequence
}
当收到发往该模块的数据包时,路由模块会调用 onRecvPacket。
function onRecvPacket(packet: Packet) {
  NonFungibleTokenPacketData data = packet.data
  // 构造默认的成功确认
  NonFungibleTokenPacketAcknowledgement ack = NonFungibleTokenPacketAcknowledgement{true, null}
  err = ProcessReceivedPacketData(data)
  if (err !== null) {
    ack = NonFungibleTokenPacketAcknowledgement{false, err.Error()}
  }
  return ack
}

function ProcessReceivedPacketData(data: NonFungibleTokenPacketData) {
  prefix = data.sourcePort + '/' + data.sourceChannel
  // 如果 classId 以前缀 packet 的 sourcePort 和 sourceChannel 开头,则我们是源链
  source = data.classId.slice(0, len(prefix)) === prefix
  for (var i in data.tokenIds) {
    if source { // 我们是源链,将代币解除托管给接收者
      nft.Transfer(data.classId.slice(len(prefix)), data.tokenIds[i], data.receiver, data.tokenData[i])
    } else { // 我们是汇链,向接收者铸造凭证
      prefixedClassId = data.destPort + '/' + data.destChannel + '/' + data.classId
      nft.CreateOrUpdateClass(prefixedClassId, data.classUri, data.classData)
      nft.Mint(prefixedClassId, data.tokenIds[i], data.tokenUris[i], data.tokenData[i], data.receiver)
    }
  }
}
当该模块发送的数据包已被确认时,路由模块会调用 onAcknowledgePacket。
function onAcknowledgePacket(packet: Packet, acknowledgement: bytes) {
  // 如果转移失败,则退还代币
  if (!acknowledgement.success) refundToken(packet)
}
当该模块发送的数据包超时时(即它不会在目标链上被接收),路由模块会调用 onTimeoutPacket。
function onTimeoutPacket(packet: Packet) {
  // 数据包已超时,因此退还代币
  refundToken(packet)
}
refundToken 会在 onAcknowledgePacket 失败时以及 onTimeoutPacket 中被调用,用于将已托管的代币退还给原始发送者。
function refundToken(packet: Packet) {
  NonFungibleTokenPacketData data = packet.data
  prefix = data.sourcePort + '/' + data.sourceChannel
  // 如果 classId 没有以数据包的 sourcePort 和 sourceChannel 为前缀,则我们是源链
  source = data.classId.slice(0, len(prefix)) !== prefix
  for (var i in data.tokenIds) {
    if source { // 我们是源链,将代币解除托管并返还给发送者
      nft.Transfer(data.classId, data.tokenIds[i], data.sender, null)
    } else { // 我们是汇链,重新向发送者铸造凭证
      nft.Mint(data.classId, data.tokenIds[i], data.tokenUris[i], data.tokenData[i], data.sender)
    }
  }
}
function onTimeoutPacketClose(packet: Packet) {
  // 不可能发生,因为只允许无序通道
}

原理说明

正确性
此实现保持了代币的非同质性和可赎回性。
  • 非同质性:在所有通过 IBC 连接的区块链中,任何代币都只有一个实例处于活跃状态。
  • 可赎回性:如果代币已被发送到对手链,则可以在源链上按相同的 classId 和 tokenId 将其赎回。

可选补充

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

进一步讨论

在本规范之上,可以支持版税、市场或许可转移等扩展且复杂的用例。解决方案可以是模块、hook、IBC 中间件 等。本规范不涵盖为此设计指导方针。 假设宿主状态机中的应用逻辑将负责保证按照本规范铸造的 IBC 代币元数据的不可变性。对于任何 IBC 代币,强烈建议 NFT 应用检查上游区块链(一直追溯到源链),以确保其元数据在传输过程中未被修改。如果未来某个时候决定支持通过 IBC 传递 NFT 元数据可变性,我们将更新本规范,或通过使用高级 DID 特性等方式创建一份全新的规范。

向后兼容性

不适用。

向前兼容性

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

示例实现

历史

日期描述
2021 年 11 月 10 日初始草案,改编自 ICS 20 规范
2021 年 11 月 17 日修订以更好地适配智能合约
2021 年 11 月 17 日从 ICS 21 更名为 ICS 721
2021 年 11 月 18 日修订为允许一个数据包包含多个代币
2022 年 2 月 10 日修订以纳入 IBC 团队的反馈
2022 年 3 月 3 日修订 TRY 回调,使其与 PR#629 保持一致
2022 年 3 月 11 日添加示例以说明前缀概念
2022 年 3 月 30 日添加 NFT 模块定义并修复伪代码错误
2022 年 5 月 18 日添加关于 NFT 元数据可变性的段落
2022 年 11 月 8 日在 PacketData 中添加 tokenData
2022 年 12 月 14 日在 PacketData 中添加 classData 和 memo
2022 年 12 月 15 日收紧对 classData 和 tokenData 的规范

版权

此处所有内容均依据 Apache 2.0 许可。
This standard document follows the same design principles of ICS 20 and inherits most of its content therefrom, while replacing bank module based asset tracking logic with that of the nft module.

Synopsis

This standard document specifies packet data structure, state machine handling logic, and encoding details for the transfer of non-fungible tokens over an IBC channel between two modules on separate chains. In this document, class, collection and contract are used interchangeably. The state machine logic presented allows for safe multi-chain classId handling with permissionless channel opening. This logic constitutes a non-fungible token transfer bridge module, interfacing between the IBC routing module and an existing asset tracking module on the host state machine, which could be either a Cosmos-style native module or a smart contract running in a virtual machine.

Motivation

Users of a set of chains connected over the IBC protocol might wish to utilize a non-fungible token on a chain other than the chain where the token was originally issued — perhaps to make use of additional features such as exchange, royalty payment or privacy protection. This application-layer standard describes a protocol for transferring non-fungible tokens between chains connected with IBC which preserves asset non-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 non-fungibility (i.e., only one instance of any token is live across all the IBC-connected blockchains).
  • Permissionless token transfers, no need to whitelist connections, modules, or classIds.
  • Symmetric (all chains implement the same logic, no in-protocol differentiation of hubs & zones).
  • Fault containment: prevents Byzantine-creation of tokens originating on chain A, as a result of chain B’s Byzantine behavior.

Technical Specification

Data Structures

Only one packet data type is required: NonFungibleTokenPacketData, which specifies the class id, class uri, class data, token id array, token uri array, token data array, sending address, and receiving address.
interface NonFungibleTokenPacketData {
  classId: string
  classUri: string
  classData: string
  tokenIds: string[]
  tokenUris: string[]
  tokenData: string[]
  sender: string
  receiver: string
  memo: string
}
classId is a required field that MUST never be empty, it uniquely identifies the class/collection/contract which the tokens being transferred belong to in the sending chain. In the case of an ERC-1155 compliant smart contract, for example, this could be a string representation of the top 128 bits of the token ID. classUri is an optional field which, if present, MUST be non-empty and refer to an off-chain resource that is typically a JSON file containing the class metadata; this could be extremely beneficial for cross-chain interoperability with NFT marketplaces like OpenSea. classData is an optional field which, if present, MUST be non-empty and contain on-chain class metadata such as royalty related parameters. tokenIds array is a required field that MUST have a size greater than zero and hold non-empty entries that uniquely identify tokens (of the given class) that are being transferred. In the case of an ERC-1155 compliant smart contract, for example, a tokenId could be a string representation of the bottom 128 bits of the token ID. tokenUris array is an optional field which, if present, MUST have the same size as tokenIds and hold non-empty entries each of which refers to an off-chain resource that is typically an immutable JSON file containing metadata associated with the token identified by the corresponding tokenIds entry. tokenData array is an optional field which, if present, MUST have the same size as tokenIds and hold non-empty entries each of which contains on-chain application data associated with the token identified by the corresponding tokenIds entry. Both tokenData entries and classData MUST be Base64 encoded strings which SHOULD have the following JSON structure:
{
  "key1" : { "value":"...", "mime":"..." },
  "key2" : { "value":"...", "mime":"..." },
  ...
}
mime is an optional property that specifies the media type of the corresponding key-value. If a key-value is of the default type of string, then mime can be omitted. Otherwise, mime MUST be non-empty and have a value that comes from this list. Chain applications are advised to namespace the keys; to achieve maximum interoperability across applications, standardization of these namespaces is desired but out of the scope. An example of classData content (raw JSON before being Base64 encoded) is shown below:
{
  "opensea:name" : { "value":"Crypto Creatures" },
  "opensea:image" : { "value":"...(Base64 encoded media binary)", "mime":"image/png" },
  "opensea:seller_fee_basis_points" : { "value":"100" }
}
The optional memo field is not used within the 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 middlewares 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. As tokens are sent across chains using the ICS-721 protocol, they begin to accrue a record of channels across which they have been transferred. This record information is encoded into the classId field. An ICS-721 token class is represented in the form {ics721Port}/{ics721Channel}/{classId}, where ics721Port and ics721Channel identify the channel on the current chain from which the tokens arrived. If {classId} contains /, then it must also be in the ICS-721 form which indicates that the tokens have a multi-hop record. Note that this requires that the / (slash character) is prohibited in non-IBC token classIds. 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 classId (once the tokens are received) adding another hop to the 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 classId is removed (once the tokens are received), undoing the last hop in the tokens record. For example, assume these steps of transfer occur: A -> B -> C -> A -> C -> B -> A
  1. A(p1,c1) -> (p2,c2)B : A is source zone. classId in B: ‘p2/c2/nftClass’
  2. B(p3,c3) -> (p4,c4)C : B is source zone. classId in C: ‘p4/c4/p2/c2/nftClass’
  3. C(p5,c5) -> (p6,c6)A : C is source zone. classId in A: ‘p6/c6/p4/c4/p2/c2/nftClass’
  4. A(p6,c6) -> (p5,c5)C : A is sink zone. classId in C: ‘p4/c4/p2/c2/nftClass’
  5. C(p4,c4) -> (p3,c3)B : C is sink zone. classId in B: ‘p2/c2/nftClass’
  6. B(p2,c2) -> (p1,c1)A : B is sink zone. classId in A: ‘nftClass’
The acknowledgement data type describes whether the transfer succeeded or failed, and the reason for failure (if any).
type NonFungibleTokenPacketAcknowledgement =
  | NonFungibleTokenPacketSuccess
  | NonFungibleTokenPacketError

interface NonFungibleTokenPacketSuccess {
  // This is binary 0x01 base64 encoded
  success: "AQ=="
}

interface NonFungibleTokenPacketError {
  error: string
}
Note that both the NonFungibleTokenPacketData as well as NonFungibleTokenPacketAcknowledgement must be JSON-encoded (not Protobuf encoded) when serialized into packet data. The non-fungible token transfer bridge module maintains a separate escrow address for each NFT channel.
interface ModuleState {
  channelEscrowAddresses: Map<Identifier, string>
}

Sub-protocols

The sub-protocols described herein should be implemented in a “non-fungible token transfer bridge” module with access to the NFT asset tracking module and the IBC routing module. The NFT asset tracking module should implement the following functions:
function CreateOrUpdateClass(classId: string, classUri: string, classData: string) {
  // creates a new NFT Class identified by classId
  // if classId already exists, app logic may choose to update class metadata accordingly
}
function Mint(classId: string, tokenId: string, tokenUri: string, tokenData: string, receiver: string) {
  // creates a new NFT identified by <classId,tokenId>
  // receiver becomes owner of the newly minted NFT
}
function Transfer(classId: string, tokenId: string, receiver: string, tokenData: string) {
  // transfers the NFT identified by <classId,tokenId> to receiver
  // receiver becomes new owner of the NFT
  // if tokenData is not empty, app logic may choose to update token data accordingly
}
function Burn(classId: string, tokenId: string) {
  // destroys the NFT identified by <classId,tokenId>
}
function GetOwner(classId: string, tokenId: string) {
  // returns current owner of the NFT identified by <classId,tokenId>
}
function GetNFT(classId: string, tokenId: string) {
  // returns NFT identified by <classId,tokenId>
}
function GetClass(classId: string) {
  // returns NFT Class identified by classId
}

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 (owned by the module).
function setup() {
  capability = routingModule.bindPort("nft", 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 non-fungible token transfer module on separate 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 ics721-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 "ics721-1"
  // or relayer passed in empty version
  abortTransactionUnless(version === "ics721-1" || version === "")
  return "ics721-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 "ics721-1"
  abortTransactionUnless(counterpartyVersion === "ics721-1")
  return "ics721-1", nil
}
function onChanOpenAck(
  portIdentifier: Identifier,
  channelIdentifier: Identifier,
  counterpartyChannelIdentifier: Identifier,
  counterpartyVersion: string
) {
  // port has already been validated
  // assert that version is "ics721-1"
  abortTransactionUnless(counterpartyVersion === "ics721-1")
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
}
function onChanOpenConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // accept channel confirmations, port has already been validated, version has already been validated
  // allocate an escrow address
  channelEscrowAddresses[channelIdentifier] = newAddress()
}
function onChanCloseInit(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // abort and return error to prevent channel closing by user
  abortTransactionUnless(FALSE)
}
function onChanCloseConfirm(
  portIdentifier: Identifier,
  channelIdentifier: Identifier
) {
  // no action necessary
}
Packet relay
  • When a non-fungible token is sent away from its source, the bridge module escrows the token on the sending chain and mints a corresponding voucher on the receiving chain.
  • When a non-fungible token is sent back toward its source, the bridge module burns the token on the sending chain and unescrows the corresponding locked token on the receiving chain.
  • When a packet times out, tokens represented in the packet are either unescrowed or minted back to the sender appropriately — depending on whether the tokens are being moved away from or back toward their source.
  • Acknowledgement data is used to handle failures, such as 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.
createOutgoingPacket 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 createOutgoingPacket(
  classId: string,
  tokenIds: string[],
  sender: string,
  receiver: string,
  destPort: string,
  destChannel: string,
  sourcePort: string,
  sourceChannel: string,
  timeoutHeight: Height,
  timeoutTimestamp: uint64): uint64 {
  prefix = sourcePort + '/' + sourceChannel
  // we are source chain if classId is not prefixed with sourcePort and sourceChannel
  source = classId.slice(0, len(prefix)) !== prefix
  tokenUris = []
  tokenData = []
  for (let tokenId in tokenIds) {
    // ensure that sender is token owner
    abortTransactionUnless(sender === nft.GetOwner(classId, tokenId))
    if source { // we are source chain, escrow token
      nft.Transfer(classId, tokenId, channelEscrowAddresses[sourceChannel], null)
    } else { // we are sink chain, burn voucher
      nft.Burn(classId, tokenId)
    }
    token = nft.GetNFT(classId, tokenId)
    tokenUris.push(token.GetUri())
    tokenData.push(token.GetData())
  }
  NonFungibleTokenPacketData data = NonFungibleTokenPacketData{
    classId,
    nft.GetClass(classId).GetUri(),
    nft.GetClass(classId).GetData(),
    tokenIds,
    tokenUris,
    tokenData,
    sender,
    receive
  }
  sequence = Handler.sendPacket(
    getCapability("port"),
    sourcePort,
    sourceChannel,
    timeoutHeight,
    timeoutTimestamp,
    protobuf.marshal(data) // protobuf-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) {
  NonFungibleTokenPacketData data = packet.data
  // construct default acknowledgement of success
  NonFungibleTokenPacketAcknowledgement ack = NonFungibleTokenPacketAcknowledgement{true, null}
  err = ProcessReceivedPacketData(data)
  if (err !== null) {
    ack = NonFungibleTokenPacketAcknowledgement{false, err.Error()}
  }
  return ack
}

function ProcessReceivedPacketData(data: NonFungibleTokenPacketData) {
  prefix = data.sourcePort + '/' + data.sourceChannel
  // we are source chain if classId is prefixed with packet's sourcePort and sourceChannel
  source = data.classId.slice(0, len(prefix)) === prefix
  for (var i in data.tokenIds) {
    if source { // we are source chain, un-escrow token to receiver
      nft.Transfer(data.classId.slice(len(prefix)), data.tokenIds[i], data.receiver, data.tokenData[i])
    } else { // we are sink chain, mint voucher to receiver
      prefixedClassId = data.destPort + '/' + data.destChannel + '/' + data.classId
      nft.CreateOrUpdateClass(prefixedClassId, data.classUri, data.classData)
      nft.Mint(prefixedClassId, data.tokenIds[i], data.tokenUris[i], data.tokenData[i], data.receiver)
    }
  }
}
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) refundToken(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
  refundToken(packet)
}
refundToken is called by both onAcknowledgePacket, on failure, and onTimeoutPacket, to refund escrowed token to the original sender.
function refundToken(packet: Packet) {
  NonFungibleTokenPacketData data = packet.data
  prefix = data.sourcePort + '/' + data.sourceChannel
  // we are the source if the classId is not prefixed with the packet's sourcePort and sourceChannel
  source = data.classId.slice(0, len(prefix)) !== prefix
  for (var i in data.tokenIds) {
    if source { // we are source chain, un-escrow token back to sender
      nft.Transfer(data.classId, data.tokenIds[i], data.sender, null)
    } else { // we are sink chain, mint voucher back to sender
      nft.Mint(data.classId, data.tokenIds[i], data.tokenUris[i], data.tokenData[i], data.sender)
    }
  }
}
function onTimeoutPacketClose(packet: Packet) {
  // can't happen, only unordered channels allowed
}

Reasoning

Correctness
This implementation preserves token non-fungibility and redeemability.
  • Non-fungibility: Only one instance of any token is live across all the IBC-connected blockchains.
  • Redeemability: If tokens have been sent to the counterparty chain, they can be redeemed back in the same classId & tokenId on the source chain.

Optional addenda

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

Further Discussion

Extended and complex use cases such as royalties, marketplaces or permissioned transfers can be supported on top of this specification. Solutions could be modules, hooks, IBC middleware and so on. Designing a guideline for this is out of the scope. It is assumed that application logic in host state machines will be responsible for metadata immutability of IBC tokens minted according to this specification. For any IBC token, NFT applications are strongly advised to check upstream blockchains (all the way back to the source) to ensure its metadata has not been modified along the way. If it is decided, sometime in the future, to accommodate NFT metadata mutability over IBC, we will update this specification or create an entirely new specification — by using advanced DID features perhaps.

Backwards Compatibility

Not applicable.

Forwards Compatibility

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

DateDescription
Nov 10, 2021Initial draft - adapted from ICS 20 spec
Nov 17, 2021Revised to better accommodate smart contracts
Nov 17, 2021Renamed from ICS 21 to ICS 721
Nov 18, 2021Revised to allow for multiple tokens in one packet
Feb 10, 2022Revised to incorporate feedbacks from IBC team
Mar 03, 2022Revised to make TRY callback consistent with PR#629
Mar 11, 2022Added example to illustrate the prefix concept
Mar 30, 2022Added NFT module definition and fixed pseudo-code errors
May 18, 2022Added paragraph about NFT metadata mutability
Nov 08, 2022Added tokenData to PacketData
Dec 14, 2022Added classData and memo to PacketData
Dec 15, 2022Tightened spec on classData and tokenData
All content herein is licensed under Apache 2.0.