Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add NFT transfer spec [WIP] #294

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions 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
321 changes: 321 additions & 0 deletions 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 <vasiliy.shapovalov@gmail.com>
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<Identifier, string>
}
```

### 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).
9 changes: 9 additions & 0 deletions 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;
}