CIS-1: Concordium Token Standard

Created

Sep 22, 2021

Final

planned Nov 1, 2021

Draft version

2 (Oct 11, 2021)

Abstract

A standard interface for both fungible and non-fungible tokens implemented in a smart contract. The interface provides functions for transferring token ownership, authenticating other addresses to transfer tokens and for other smart contracts to access token balances. It allows for off-chain applications to track token balances, authentication and the location of token metadata using logged events.

Specification

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

General types and serialization

TokenID

Token Identifier, which combined with the address of the smart contract instance implementing CIS1, forms the globally unique identifier of a token type.

  • A token ID for a token type SHALL NOT change after a token type have been minted.

  • A token ID for a token type SHALL NOT be reused for another token type within the same smart contract.

It is serialized as 1 byte for the size (n) of the identifier, followed by this number of bytes for the token id (id):

TokenID ::= (n: Byte) (id: Byteⁿ)

Note

Token IDs can be as small as a single byte (by setting the first byte to the value 0) or as big as 256 bytes leaving more than 10^614 possible token IDs. The token ID could be an encoding of a small text string or some checksum hash, but to save energy it is still recommended to use small token IDs if possible.

TokenAmount

An amount of a token type is an unsigned 64 bit integer.

It is serialized using 8 bytes little endian:

TokenAmount ::= (amount: Byte⁸)

ReceiveHookName

A smart contract receive function name. A receive function name is prefixed with the contract name, followed by a . and a name for the function. It MUST consist only of ASCII alphanumeric or punctuation characters. The contract name is not allowed to contain ..

It is serialized as: the function name byte length (n) is represented by the first 2 bytes, followed by this many bytes for the function name (name). The receive function name MUST be 100 bytes or less:

ReceiveHookName ::= (n: Byte²) (name: Byteⁿ)

Note

This type is passed in a parameter for smart contract function calls, be aware of the parameter size limit of 1024 bytes.

ContractName

A name of a smart contract. It must be prefixed with init_ and MUST consist only of ASCII alphanumeric or punctuation characters. The contract name is not allowed to contain ..

It is serialized as: the contract name byte length (n) is represented by the first 2 bytes, followed by this many bytes for the contract name (name). The contract name MUST be 100 bytes or less:

ContractName ::= (n: Byte²) (name: Byteⁿ)

AccountAddress

An address of an account.

It is serialized as 32 bytes:

AccountAddress ::= (address: Byte³²)

ContractAddress

An address of a contract instance. It consists of an index and a subindex both unsigned 64 bit integers.

It is serialized as: first 8 bytes for the index (index) followed by 8 bytes for the subindex (subindex) both little endian:

ContractAddress ::= (index: Byte⁸) (subindex: Byte⁸)

Address

Is either an account address or a contract address.

It is serialized as: First byte indicates whether it is an account address or a contract address. In case the first byte is 0 then AccountAddress (address) is followed. In case the first byte is 1 then ContractAddress (address) is followed:

Address ::= (0: Byte) (address: AccountAddress)
          | (1: Byte) (address: ContractAddress)

Receiver

The receiving address of a transfer, which is either an account address or a contract address. In the case of a contract address: a name of the hook receive function to invoke is also needed.

It is serialized as: First byte indicates whether it is an account address or a contract address. In case the first byte is 0 then AccountAddress (address) is followed. In case the first byte is 1 then ContractAddress (address), bytes for ReceiveHookName (hook) is followed:

Receiver ::= (0: Byte) (address: AccountAddress)
           | (1: Byte) (address: ContractAddress) (hook: ReceiveHookName)

AdditionalData

Additional bytes to include in a transfer, which can be used to add additional parameters for the transfer function call.

It is serialized as: the first 2 bytes encode the length (n) of the data, followed by this many bytes for the data (data):

AdditionalData ::= (n: Byte²) (data: Byteⁿ)

Note

This type is passed in a parameter for smart contract function calls. Be aware of the parameter size limit of 1024 bytes.

Contract functions

A smart contract implementing CIS1 MUST export three functions transfer, updateOperator and balanceOf according to the following description:

transfer

Executes a list of token transfers. A transfer is a token ID, an amount of tokens to be transferred, and the from address and to address.

When transferring tokens to a contract address additional information for a receive function hook to trigger is required.

Parameter

The parameter is a list of transfers.

It is serialized as: 2 bytes representing the number of transfers (n) followed by the bytes for this number of transfers (transfers). Each transfer is serialized as: a TokenID (id), a TokenAmount (amount), the token owner address Address (from), the receiving address Receiver (to) and some additional data (data):

Transfer ::= (id: TokenID) (amount: TokenAmount) (from: Address) (to: Receiver) (data: AdditionalData)

TransferParameter ::= (n: Byte²) (transfers: Transferⁿ)

Note

Be aware of the smart contract parameter size limit of 1024 bytes. Since the byte size of a single transfer can vary in size, this will limit the number of transfers that can be included in the same function call. Currently, with the smallest possible transfers, the parameter can contain 21 transfers and with the biggest possible transfer, it will take the whole parameter.

Receive hook parameter

The parameter for the receive function hook contains information about the transfer, the name of the token contract and some additional data bytes.

It is serialized as: a TokenID (id), a TokenAmount (amount), the token owner address Address (from), the name of the token contract ContractName (contract) and AdditionalData (data):

ReceiveHookParameter ::= (id: TokenID) (amount: TokenAmount) (from: Address) (contract: ContractName) (data: AdditionalData)
Requirements
  • The list of transfers MUST be executed in order.

  • The contract function MUST reject if any of the transfers fails to be executed.

  • A transfer MUST fail if:

  • A transfer MUST non-strictly decrease the balance of the from address and non-strictly increase the balance of the to address or fail.

  • A transfer with the same address as from and to MUST be executed as a normal transfer.

  • A transfer of a token amount zero MUST be executed as a normal transfer.

  • A transfer of some amount of a token type MUST only transfer the exact amount of the given token type between balances.

  • A transfer of any amount of a token type to a contract address MUST call receive hook function on the receiving smart contract with a receive hook parameter.

  • Let operator be an operator of the address owner. A transfer of any amount of a token type from an address owner sent by an address operator MUST be executed as if the transfer was sent by owner.

  • The contract function MUST reject if a receive hook function called on the contract receiving tokens rejects.

Warning

Be aware of transferring tokens to a non-existing account address. This specification by itself does not include a mechanism to recover these tokens. Checking the existence of an account address would ideally be done off-chain before the message is even sent to the token smart contract.

updateOperator

Add or remove a number of addresses as operators of the address sending this message.

Parameter

The parameter contains a list of operator updates. An operator update contains information whether to add or remove an operator and the address to add/remove as operator. It does not contain the address which is adding/removing the operator as this will be the sender of the message invoking this function.

The parameter is serialized as: first 2 bytes (n) for the number of updates followed by this number of operator updates (updates). An operator update is serialized as: 1 byte (update) indicating whether to remove or add an operator, where if the byte value is 0 the sender is removing an operator, if the byte value is 1 the sender is adding an operator. The is followed by the operator address (operator) Address to add or remove as operator for the sender:

OperatorUpdate ::= (0: Byte) // Remove operator
                 | (1: Byte) // Add operator

UpdateOperator ::= (update: OperatorUpdate) (operator: Address)

UpdateOperatorParameter ::= (n: Byte²) (updates: UpdateOperatorⁿ)
Requirements
  • The list of updates MUST be executed in order.

  • The contract function MUST reject if any of the updates fails to be executed.

balanceOf

Query balances of a list of addresses and token IDs, the result is then sent back to the sender.

Parameter

The parameter consists of a name of the receive function to callback with the result and a list of token ID and address pairs.

It is serialized as: ReceiveHookName (callback) followed by 1 byte for the number of queries (n) and then this number of queries (queries). A query is serialized as TokenID (id) followed by Address (address):

BalanceOfQuery ::= (id: TokenID) (address: Address)

BalanceOfParameter ::= (callback: ReceiveFunctionName) (n: Byte) (queries: BalanceOfQueryⁿ)

Note

Be aware of the size limit on contract function parameters which currently is 1024 bytes, which puts a limit on the number of queries depending on the byte size of the Token ID and the name of the receive function.

Callback parameter

The parameter for the callback receive function is a list of query and token amount pairs.

It is serialized as: 2 bytes for the number of query-amount pairs (n) and then this number of pairs (results). A query-amount pair is serialized as a query (query) and then a TokenAmount (amount):

BalanceOfQueryResult ::= (query: BalanceOfQuery) (balance: TokenAmount)

BalanceOfCallbackParameter ::= (n: Byte²) (results: BalanceOfQueryResultⁿ)
Requirements
  • The contract function MUST reject if the sender is not a contract address with error CONTRACT_ONLY.

  • The contract function MUST reject if any of the queries fail:

Logged events

The idea of the logged events for this specification is for off-chain applications to be able to track balances and operators without knowledge of the contract-specific implementation details. For this reason it is important to log events in any functionality of the token contract which modifies balances or operators.

  • It MUST be possible to derive the balance of an address for a token type from the logged TransferEvent, MintEvent and BurnEvent events.

  • It MUST be safe to assume that with no events logged, every address have zero tokens and no operators enabled for any address.

The events defined by this specification are serialized using one byte to the discriminate the different events. Any custom event SHOULD NOT have a first byte colliding with any of the events defined by this specification.

TransferEvent

A TransferEvent event MUST be logged for every amount of a token type changing ownership from one address to another.

The TransferEvent event is serialized as: first a byte with the value of 255, followed by the token ID TokenID (id), an amount of tokens TokenAmount (amount), from address Address (from) and to address Address (to):

TransferEvent ::= (255: Byte) (id: TokenID) (amount: TokenAmount) (from: Address) (to: Address)

MintEvent

A MintEvent event MUST be logged every time a new token is minted. This also applies when introducing new token types and the initial token types and amounts in a contract. Minting a token with a zero amount can be used to indicating the existence of a token type without minting any amount of tokens.

The MintEvent event is serialized as: first a byte with the value of 254, followed by the token ID TokenID (id), an amount of tokens being minted TokenAmount (amount) and the owner address of the tokens Address (to):

MintEvent ::= (254: Byte) (id: TokenID) (amount: TokenAmount) (to: Address)

Note

Be aware of the limit on the number of logs per smart contract function call which currently is 64. A token smart contract function which needs to mint a large number of token types with token metadata might hit this limit.

BurnEvent

A BurnEvent event MUST be logged every time an amount of a token type is burned.

Summing all of the minted amounts from MintEvent events and subtracting all of the burned amounts from BurnEvent events for a token type MUST sum up to the total supply for the token type. The total supply of a token type MUST be in the inclusive range of [0, 2^64 - 1].

The BurnEvent event is serialized as: first a byte with the value of 253, followed by the token ID TokenID (id), an amount of tokens being burned TokenAmount (amount) and the owner address of the tokens Address (from):

BurnEvent ::= (253: Byte) (id: TokenID) (amount: TokenAmount) (from: Address)

UpdateOperatorEvent

The event to log when updating an operator of some address.

The UpdateOperatorEvent event is serialized as: first a byte with the value of 252, followed by a OperatorUpdate (update), then the owner address updating an operator Address (owner) and an operator address Address (operator) being added or removed:

UpdateOperatorEvent ::= (252: Byte) (update: OperatorUpdate) (owner: Address) (operator: Address)

TokenMetadataEvent

The event to log when setting the metadata url for a token type. It consists of a token ID and an URL (RFC 3986) for the location of the metadata for this token type with an optional SHA256 checksum of the content. Logging the TokenMetadataEvent event again with the same token ID, is used to update the metadata location and only the most recently logged token metadata event for certain token id should be used to get the token metadata.

The TokenMetadataEvent event is serialized as: first a byte with the value of 251, followed by the token ID TokenID (id), two bytes for the length of the metadata url (n) and then this many bytes for the url to the metadata (url). Lastly a byte to indicate whether a hash of the metadata is included, if its value is 0, then no content hash, if the value is 1 then 32 bytes for a SHA256 hash (hash) is followed:

MetadataUrl ::= (n: Byte²) (url: Byteⁿ)

MetadataChecksum ::= (0: Byte)
                   | (1: Byte) (hash: Byte³²)

TokenMetadataEvent ::= (251: Byte) (id: TokenID) (metadata: MetadataUrl) (checksum: MetadataChecksum)

Note

Be aware of the limit on the number of logs per smart contract function call, which currently is 64, and also the byte size limit on each logged event, which currently is 512 bytes. This will limit the length of the metadata URL depending on the size of the token ID and whether a content hash is included. With the largest possible token ID and a content hash included; the URL can be up to 220 bytes.

Rejection errors

A smart contract following this specification MUST reject the specified errors found in this specification with the following error codes:

Name

Error code

Description

INVALID_TOKEN_ID

-42000001

A provided token ID it not part of this token contract.

INSUFFICIENT_FUNDS

-42000002

An address balance contains insufficient amount of tokens to complete some transfer of a token.

UNAUTHORIZED

-42000003

Sender is not the address owning the tokens or an operator of the owning address. Note this can also be used if adding another authentication level on top of the standard.

CONTRACT_ONLY

-42000004

The sender is not a contract address.

The smart contract implementing this specification MAY introduce custom error codes other than the ones specified in the table above.

Token metadata JSON

The token metadata is stored off chain and MUST be a JSON (RFC 8259) file.

All of the fields in the JSON file are optional, and this specification reserves a number of field names, shown in the table below.

Token metadata JSON Object

Property

JSON value type [JSON-Schema]

Description

name (optional)

string

The name to display for the token type.

symbol (optional)

string

Short text to display for the token type.

decimals (optional)

number [integer]

The number of decimals, when displaying an amount of this token type in a user interface. If the decimal is set to d then a token amount a should be displayed as a * 10^(-d)

description (optional)

string

A description for this token type.

thumbnail (optional)

URL JSON object

An image URL to a small image for displaying the asset.

display (optional)

URL JSON object

An image URL to a large image for displaying the asset.

artifact (optional)

URL JSON object

A URL to the token asset.

assets (optional)

JSON array of Token metadata JSON objects

Collection of assets.

attributes (optional)

JSON array of Attribute JSON objects

Assign a number of attributes to the token type. Attributes can be used to include extra information about the token type.

localization (optional)

JSON object with locales as field names (RFC 5646) and field values are URL JSON object to JSON files.

URL’s to JSON files with localized token metadata.

Optionally a SHA256 hash of the JSON file can be logged with the TokenMetadata event for checking integrity. Since the metadata json file could contain URLs, a SHA256 hash can optionally be associated with the URL. To associate a hash with a URL the JSON value is an object:

URL JSON Object

Property

JSON value type [JSON-Schema]

Description

url

string (RFC 3986) [uri-reference]

An URL.

hash (optional)

string

A SHA256 hash of the URL content encoded as a hex string.

Attributes are objects with the following fields:

Attribute JSON object

Property

JSON value type [JSON-Schema]

Description

type

string

Type for the value field of the attribute.

name

string

Name of the attribute.

value

string

Value of the attrbute.

Example token metadata: Fungible

An example of token metadata for a CIS1 implementation wrapping the GTU could be:

{
  "name": "Wrapped GTU Token",
  "symbol": "wGTU",
  "decimals": 6,
  "description": "A CIS1 token wrapping the Global Transaction Unit",
  "thumbnail": { "url": "https://location.of/the/thumbnail.png" },
  "display": { "url": "https://location.of/the/display.png" },
  "artifact": { "url": "https://location.of/the/artifact.png" },
  "localization": {
    "da-DK": {
      "url": "https://location.of/the/danish/metadata.json",
      "hash": "624a1a7e51f7a87effbf8261426cb7d436cf597be327ebbf113e62cb7814a34b"
    }
  }
}

The danish localization JSON file could be:

{
  "description": "CIS1 indpakket GTU"
}

Example token metadata: Non-fungible

An example of token metadata for a NFT could be:

{
  "name": "Bibi - The Ryan Cat",
  "description": "Ryan cats are lonely creatures travelling the galaxy in search of their ancestors and true inheritance",
  "thumbnail": { "url": "https://location.of/the/thumbnail.png" },
  "display": { "url": "https://location.of/the/display.png" },
  "attributes": [{
    "type": "date",
    "name": "Birthday",
    "value": "1629792199610"
  }, {
    "type": "string",
    "name": "Body",
    "value": "Strong"
  }, {
    "type": "string",
    "name": "Head",
    "value": "Round"
  }, {
    "type": "string",
    "name": "Tail",
    "value": "Short"
  }],
  "localization": {
    "da-DK": {
      "url": "https://location.of/the/danish/metadata.json",
      "hash": "588d7c14883231cfee522479cc66565fd9a50024603a7b8c99bd7869ca2f0ea3"
    }
  }
}

The danish localization JSON file could be:

{
  "name": "Bibi - Ryan katten",
  "description": "Ryan katte er ensomme væsner, som rejser rundt i galaxen søgende efter deres forfædre og sande fortid"
}

Smart contract limitations

A number of limitations are important to be aware of:

  • Smart contract state size is limited to 16 KiB.

  • Smart contract function parameters are limited to 1 KiB.

  • Each logged event is limited to 0.5 KiB.

  • The number of logged events is limited to 64 per contract function invocation.

  • The total size of the smart contract module is limited to 64 KiB.

Note

Smart contracts, where the contract state size limit is too low, can distribute the state across multiple smart contract instances.

Decisions and rationale

In this section we point out some of the differences from other popular token standards found on other blockchains, and provide reasons for deviating from them in CIS1.

Token ID bytes instead an integer

Token standards such as ERC721 and ERC1155 both use a 256-bit unsigned integer (32 bytes) for the token ID, to support using something like a SHA256 hash for the token ID. But in the case where the token ID have no significance other than a simple identifier, smaller sized token IDs can reduce energy costs. This is why we chose to let the first byte indicate the size of the token ID, meaning a token ID can vary between 1 byte and 256 bytes, resulting in more than 10^614 possible token IDs.

Only batched transfers

The specification only has a transfer smart contract function which takes list of transfer and no function for a single transfer. This will result in lower energy cost compared to multiple contract calls and only introduces a small overhead for single transfers. The reason for not also including a single transfer function is to have smaller smart contract modules, which in turn leads to saving cost on every function call.

No token level approval/allowance like in ERC20 and ERC721

This standard only specifies address-level operators and no authentication on per token level. The main argument is simplicity and to save energy cost on common cases, but other reasons are:

  • A token level authentication requires the token smart contract to track more state, which increases the overall energy cost.

  • For token smart contracts with a lot of token types, such as a smart contract with a large collection of NFTs, a token level authentication could become very expensive.

  • For fungible tokens; approval/allowance introduces an attack vector.

Note

The specification does not prevent adding more fine-grained authentication, such as a token level authentication.

Operator can transfer any amount of any token type for the owner

An operator of an address can transfer any amount of any token type owned by the address. An alternative approach could be to scope the operators per token type and the owner could then add the operator for every token type to achieve the same. Although it is a more flexible approach in terms of functionality, the complexity will require more of the contract implementation and the general interaction by off-chain integrations and other smart contracts, which in turn would increase the energy cost.

However, if a more fine grained authentication system is needed it can still exist next to the operators.

Receive hook function

The specification requires a token receive hook to be called on a smart contract receiving tokens, this will in some cases prevent mistakes such as sending tokens to smart contracts which do not define behavior for receiving tokens. These token could then be lost forever.

The reason for this not being optional is to allow other smart contracts which integrate with a token smart contract to rely on this for functionality.

Warning

The smart contract receive hook function could be called by any smart contract and it is up to the integrating contract whether to trust the token contract.

Receive hook function callback argument

The name of the receive hook function called on a smart contract receiving tokens is supplied as part of the parameter. This allows for a smart contract integrating with a token smart contract to have multiple hooks and leave it to the caller to know which hook they want to trigger.

Another technical reason is that the name of the smart contract is part of the smart contract receive function name, which means the specification would include a requirement of the smart contract name for others to integrate reliably.

No sender hook function

The FA2 token standard found on Tezos, allows for a hook function to be called on a smart contract sending tokens, such that the contract could reject the transfer on some criteria. This seems to only make sense, if some operator is transferring tokens from a contract, in which case the sender smart contract might as well contain the logic to transfer the tokens and trigger this directly.

Explicit events for mint and burn

ERC20, ERC721 and ERC1155 use a transfer event from or to the zero address to indicate mint and burn respectively, but since there are no such thing as the zero address on the Concordium blockchain these events are separate. Making it more explicit instead of special case transfer events.

No error code for receive hook rejecting

The specification could include an error code for the receive hook function to return if rejecting the token transferred (as seen in the FA2 standard on Tezos). But we chose to leave this error code up to the receiving smart contract, which allows for more informative error codes.

Adding SHA256 checksum for token metadata event

A token can optionally include a SHA256 checksum when logging the token metadata event, this is to ensure the integrity of the token metadata. This checksum can be updated by logging a new event.