diff --git a/spec/ics-017-nonfungible-token-transfer/Makefile b/spec/ics-017-nonfungible-token-transfer/Makefile new file mode 100644 index 000000000..04d2d8ee9 --- /dev/null +++ b/spec/ics-017-nonfungible-token-transfer/Makefile @@ -0,0 +1,10 @@ +typecheck: + +build: + +clean: + +check_proto: + /bin/bash -c 'TEMP=$$(mktemp); protoc packets.proto -o $$TEMP; rm -f $$TEMP' + +.PHONY: typecheck build clean check_proto diff --git a/spec/ics-017-nonfungible-token-transfer/README.md b/spec/ics-017-nonfungible-token-transfer/README.md new file mode 100644 index 000000000..f8de0cc79 --- /dev/null +++ b/spec/ics-017-nonfungible-token-transfer/README.md @@ -0,0 +1,321 @@ +--- +ics: 20 +title: Nonfungible Token Transfer +stage: draft +category: IBC/APP +requires: 25, 26 +author: Vasiliy Shapovalov +created: 2019-10-22 +modified: 2019-10-22 +--- + +## Synopsis + +This standard document specifies packet data structure, state machine handling logic, and encoding details for the transfer of nonfungible tokens over an IBC channel between two modules on separate chains. The state machine logic presented allows for safe multi-chain asset handling with permissionless channel opening. This logic constitutes a "nonfungible token transfer bridge module", interfacing between the IBC routing module and an existing nonfungible asset tracking module on the host state machine. + +### Motivation + +Users of a set of chains connected over the IBC protocol might wish to utilise a nonfungible asset issued on one chain on another chain, perhaps to make use of additional features such as exchange or privacy protection, while retaining ability to handle it on the origin chain when necessary. This application-layer standard describes a protocol for transferring nonfungible tokens between chains connected with IBC which 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](../ics-025-handler-interface) and [ICS 26](../ics-026-routing-module), respectively. + +### Desired Properties + +- Reversibility (two-way transfer is possible). +- Preservation of total supply (unique assets should stay unique after transfer). +- 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, `NonfungibleTokenPacketData`, which specifies the asset id, asset metadata URI (or onchain metadata), sending account, receiving account, and whether the sending chain is the source of the asset, is required. + +```typescript +interface NonfungibleTokenPacketData { + id: string + tokenMetadataUri: string + sender: string + receiver: string + source: boolean +} +``` + +The nonfungible token transfer bridge module tracks escrow addresses associated with particular channels in state. Fields of the `ModuleState` are assumed to be in scope. + +```typescript +interface ModuleState { + channelEscrowAddresses: Map +} +``` + +### Sub-protocols + +The sub-protocols described herein should be implemented in a "nonfungible 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). + +```typescript +function setup() { + routingModule.bindPort("bank", ModuleCallbacks{ + onChanOpenInit, + onChanOpenTry, + onChanOpenAck, + onChanOpenConfirm, + onChanCloseInit, + onChanCloseConfirm, + onRecvPacket, + onTimeoutPacket, + onAcknowledgePacket, + onTimeoutPacketClose + }) +} +``` + +Once the `setup` function has been called, channels can be created through the IBC routing module between instances of the nonfungible 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 (TODO) + +Both machines `A` and `B` accept new channels from any module on another machine, if and only if: + +- The other module is bound to the "bank" port. +- The channel being created is unordered. +- The version string is empty. + +```typescript +function onChanOpenInit( + order: ChannelOrder, + connectionHops: [Identifier], + portIdentifier: Identifier, + channelIdentifier: Identifier, + counterpartyPortIdentifier: Identifier, + counterpartyChannelIdentifier: Identifier, + version: string) { + // only unordered channels allowed + abortTransactionUnless(order === UNORDERED) + // only allow channels to "bank" port on counterparty chain + abortTransactionUnless(counterpartyPortIdentifier === "bank") + // version not used at present + abortTransactionUnless(version === "") + // allocate an escrow address + channelEscrowAddresses[channelIdentifier] = newAddress() +} +``` + +```typescript +function onChanOpenTry( + order: ChannelOrder, + connectionHops: [Identifier], + portIdentifier: Identifier, + channelIdentifier: Identifier, + counterpartyPortIdentifier: Identifier, + counterpartyChannelIdentifier: Identifier, + version: string, + counterpartyVersion: string) { + // only unordered channels allowed + abortTransactionUnless(order === UNORDERED) + // version not used at present + abortTransactionUnless(version === "") + abortTransactionUnless(counterpartyVersion === "") + // only allow channels to "bank" port on counterparty chain + abortTransactionUnless(counterpartyPortIdentifier === "bank") + // allocate an escrow address + channelEscrowAddresses[channelIdentifier] = newAddress() +} +``` + +```typescript +function onChanOpenAck( + portIdentifier: Identifier, + channelIdentifier: Identifier, + version: string) { + // version not used at present + abortTransactionUnless(version === "") + // port has already been validated +} +``` + +```typescript +function onChanOpenConfirm( + portIdentifier: Identifier, + channelIdentifier: Identifier) { + // accept channel confirmations, port has already been validated +} +``` + +```typescript +function onChanCloseInit( + portIdentifier: Identifier, + channelIdentifier: Identifier) { + // no action necessary +} +``` + +```typescript +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 nonfugible asset on the sending chain and mints a voucher on the receiving chain. +- When acting as the sink zone, the bridge module burns a local voucher on the sending chains and unescrows the local asset 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. +- No acknowledgement data is necessary. + +`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. + +```typescript +function createOutgoingPacket( + id: string, + sender: string, + receiver: string, + source: boolean) { + if source { + // sender is source chain: escrow tokens + // determine escrow account + escrowAccount = channelEscrowAddresses[packet.sourceChannel] + // construct receiving denomination, check correctness + prefix = "{packet/destPort}/{packet.destChannel}" + abortTransactionUnless(denomination.slice(0, len(prefix)) === prefix) + // escrow source tokens (assumed to fail if balance insufficient) + bank.TransferCoins(sender, escrowAccount, denomination.slice(len(prefix)), amount) + } else { + // receiver is source chain, burn vouchers + // construct receiving denomination, check correctness + prefix = "{packet/sourcePort}/{packet.sourceChannel}" + abortTransactionUnless(denomination.slice(0, len(prefix)) === prefix) + // burn vouchers (assumed to fail if balance insufficient) + bank.BurnCoins(sender, denomination, amount) + } + FungibleTokenPacketData data = FungibleTokenPacketData{denomination, amount, sender, receiver, source} + handler.sendPacket(packet) +} +``` + +`onRecvPacket` is called by the routing module when a packet addressed to this module has been received. + +```typescript +function onRecvPacket(packet: Packet): bytes { + FungibleTokenPacketData data = packet.data + if data.source { + // sender was source chain: mint vouchers + // construct receiving denomination, check correctness + prefix = "{packet/destPort}/{packet.destChannel}" + abortTransactionUnless(data.denomination.slice(0, len(prefix)) === prefix) + // mint vouchers to receiver (assumed to fail if balance insufficient) + bank.MintCoins(data.receiver, data.denomination, data.amount) + } else { + // receiver is source chain: unescrow tokens + // determine escrow account + escrowAccount = channelEscrowAddresses[packet.destChannel] + // construct receiving denomination, check correctness + prefix = "{packet/sourcePort}/{packet.sourceChannel}" + abortTransactionUnless(data.denomination.slice(0, len(prefix)) === prefix) + // unescrow tokens to receiver (assumed to fail if balance insufficient) + bank.TransferCoins(escrowAccount, data.receiver, data.denomination.slice(len(prefix)), data.amount) + } + return 0x +} +``` + +`onAcknowledgePacket` is called by the routing module when a packet sent by this module has been acknowledged. + +```typescript +function onAcknowledgePacket( + packet: Packet, + acknowledgement: bytes) { + // nothing is necessary, likely this will never be called since it's a no-op +} +``` + +`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). + +```typescript +function onTimeoutPacket(packet: Packet) { + FungibleTokenPacketData data = packet.data + if data.source { + // sender was source chain, unescrow tokens + // determine escrow account + escrowAccount = channelEscrowAddresses[packet.destChannel] + // construct receiving denomination, check correctness + prefix = "{packet/sourcePort}/{packet.sourceChannel}" + abortTransactionUnless(data.denomination.slice(0, len(prefix)) === prefix) + // unescrow tokens back to sender + bank.TransferCoins(escrowAccount, data.sender, data.denomination.slice(len(prefix)), data.amount) + } else { + // receiver was source chain, mint vouchers + // construct receiving denomination, check correctness + prefix = "{packet/sourcePort}/{packet.sourceChannel}" + abortTransactionUnless(data.denomination.slice(0, len(prefix)) === prefix) + // mint vouchers back to sender + bank.MintCoins(data.sender, data.denomination, data.amount) + } +} +``` + +```typescript +function onTimeoutPacketClose(packet: Packet) { + // can't happen, only unordered channels allowed +} +``` + +#### Reasoning + +##### Correctness + +This implementation preserves both fungibility & supply. + +Reversibilty: If tokens have been sent to the counterparty chain, they can be redeemed back in the same denomination & amount on the source chain. + +Supply: Redefine supply as unlocked tokens. All send-recv pairs sum to net zero. Source chain can change supply. + +##### Multi-chain notes + +This does not yet handle the "diamond problem", where a user sends a token originating on chain A to chain B, then to chain D, and wants to return it through D -> C -> A — since the supply is tracked as owned by chain B, chain C cannot serve as the intermediary. It is not yet clear whether that case should be dealt with in-protocol or not — it may be fine to just require the original path of redemption (and if there is frequent liquidity and some surplus on both paths the diamond path will work most of the time). Complexities arising from long redemption paths may lead to the emergence of central chains in the network topology. + +#### Optional addenda + +- Each chain, locally, could elect to keep a lookup table to use short, user-friendly local denominations in state which are translated to and from the longer denominations when sending and receiving packets. +- Additional restrictions may be imposed on which other machines may be connected to & which channels may be established. + +## Backwards Compatibility + +Not applicable. + +## Forwards Compatibility + +A future version of this standard could use a different version in the channel handshake. + +## Example Implementation + +Coming soon. + +## Other Implementations + +Coming soon. + +## History + +24 October 2019 - Draft written + +## Copyright + +All content herein is licensed under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/spec/ics-017-nonfungible-token-transfer/packets.proto b/spec/ics-017-nonfungible-token-transfer/packets.proto new file mode 100644 index 000000000..875f36b97 --- /dev/null +++ b/spec/ics-017-nonfungible-token-transfer/packets.proto @@ -0,0 +1,9 @@ +syntax = 'proto3'; + +message FungibleTokenPacketData { + string id = 1; + string tokenMetadataUri = 2; + string sender = 3; + string receiver = 4; + bool source = 5; +}