Ksana
中文

Build

KS-721: non-fungible tokens

The standard for collections of unique tokens, each with a 32-byte ID and an owner. Wallets, markets and other contracts read and move every KS-721 token the same way.

On this page

Built-in assets are fungible amounts, so the VM cannot say who holds a given item: a collection is a contract that keeps the owner of each token in its own storage. KS-721 fixes its methods, events and refusals, so that a wallet shows any collection’s tokens and a market sells any of them. Like KS-20, it is a convention between contracts, wallets and indexers, not a consensus rule, specified in docs/STANDARDS.md; Assets and tokens covers what the two share.

Interface

KS-721
view standards() -> Vec<String>                                       // ["KS-721", extensions...]
view metadata() -> (String, String)                                   // (name, symbol)
view capabilities() -> Vec<String>
view owner_of(token_id: [u8; 32]) -> Option<Account>                  // None for a token that does not exist, or was burned
view balance_of(owner: Account) -> u64                                // how many tokens owner holds
view token_uri(token_id: [u8; 32]) -> Option<String>
view approved(token_id: [u8; 32]) -> Option<Account>
view is_operator(owner: Account, operator: Account) -> bool                    // now: false once expired
view operator_info(owner: Account, operator: Account) -> (bool, Option<u64>)   // (approved, expires_at) as set
call transfer(to: Account, token_id: [u8; 32])                        // the owner only
call transfer_from(from: Account, to: Account, token_id: [u8; 32])    // the owner, the approved account or an operator
call approve(spender: Option<Account>, token_id: [u8; 32])            // the owner or an operator; None clears
call set_operator(operator: Account, approved: bool, expires_at: Option<u64>)
payable call transfer_and_call(to: ContractId, token_id: [u8; 32], data: Bytes) -> Bytes   // the owner only
event standards(ids: Vec<String>)
event metadata(name: String, symbol: String)
event transfer(from: Account, to: Account, token_id: [u8; 32])
event approve(owner: Account, spender: Option<Account>, token_id: [u8; 32])
event operator(owner: Account, operator: Account, approved: bool, expires_at: Option<u64>)
event uri(token_id: Option<[u8; 32]>)

A contract that takes tokens in one step adds payable call on_nft_received(from: Account, token_id: [u8; 32], data: Bytes) -> Bytes. Minting is the collection’s own method, as is init: collections number their tokens in different ways, so the standard leaves it out, and a collection that can still mint reports mint in capabilities().

Rules

  1. Declaration: init emits standards, then metadata, before any transfer, and standards() starts with "KS-721". A contract never declares both KS-20 and KS-721.
  2. Every change of owner is an event: every mint, transfer and burn emits transfer, a mint from the zero account and a burn to it, and balance_of is the number of tokens an account holds.
  3. Approvals end with the owner: a change of owner clears the token’s approved account, with no event of its own.
  4. Who moves a token: transfer and transfer_and_call are the owner’s alone (not the owner). transfer_from is the owner’s, the approved account’s or an operator’s (not authorized), and from must own the token now (wrong owner), so a market’s old listing cannot sell a token its owner moved to another wallet.
  5. Recipients: neither the zero account (zero account) nor the collection itself (recipient is this contract), nor the token’s owner (already the owner).
  6. IDs are minted once: an ID that a token has (token exists) or had before it was burned (token was burned) is never minted again.
  7. Attached funds: every method but transfer_and_call reverts with unexpected attached funds.
  8. Approvals and operators: the approved account is never the zero account nor the owner (spender is the owner). An operator moves, approves and burns every token of the owner, present and future, while the block timestamp is below expires_at (always for None); set_operator with approved false takes it back.
  9. Unknown tokens: a method naming a token that does not exist reverts with unknown token; the views answer None.
  10. A collection’s own rules refuse with a reason that starts with rejected: .
  11. URIs: a change of a token’s URI after its mint emits uri(Some(token_id)), and a change of every token’s (a new base URI) uri(None), so wallets read them again.

transfer_and_call works as KS-20’s: the owner calls it on the collection, which moves the token to the contract to, calls its on_nft_received(owner, token_id, data) with the attached funds, and returns its answer; when the recipient refuses or fails, the whole operation fails and the token stays with its owner. The recipient checks that caller() is a collection it accepts.

Token IDs and URIs

A token ID is 32 bytes, as long as an account or a transaction ID, so it can hold a hash: a name service can use the SHA-256 of a name, a collection that follows Bitcoin transactions their IDs. A serial number n is written big-endian in the last 16 bytes, so storage keys sort by serial number, and wallets show an ID whose first 16 bytes are zero as #n.

token_uri points to a JSON document in ERC-721’s metadata format (name, description, image, attributes), by a data:, https:, ipfs: or ar: URI; a wallet that finds no JSON there shows the URI as an image or as text. A data: URI stored on chain is bound by the 64 KiB limit of a storage value, and the reference implementation takes URIs of up to 2,048 bytes. Wallets show images with <img> only, SVG included, and load remote ones through a proxy or when you open them.

Events

Event Fields When
standards ids: Vec<String> in init, once
metadata name: String, symbol: String in init, and again when the name or the symbol changes
transfer from: Account, to: Account, token_id: [u8; 32] every mint, transfer and burn
approve owner: Account, spender: Option<Account>, token_id: [u8; 32] every approve; None clears
operator owner: Account, operator: Account, approved: bool, expires_at: Option<u64> every set_operator
uri token_id: Option<[u8; 32]> a token’s URI changed after its mint (None: every token’s)

Extensions

KS-721-burnable
call burn(token_id: [u8; 32])                    // the owner, the approved account or an operator
KS-721-enumerable
view total_supply() -> u64                                            // how many tokens exist
view tokens(start: u64, limit: u32) -> Vec<[u8; 32]>                  // every token, a page from position start
view tokens_of(owner: Account, start: u64, limit: u32) -> Vec<[u8; 32]>   // owner's tokens, a page

A page holds at most 100 IDs, and a start past the end gives an empty page. Keeping the lists costs gas at every mint, transfer and burn, which is why the extension is optional: wallets can list a holder’s tokens from a node’s index without it.

Build a collection

The contract SDK implements KS-721 in its ks721 module, behind the same standards feature as KS-20. Ks721::new() with .burnable() and .enumerable() answers every standard method in Ks721::dispatch; the collection adds its own init and mint, and ks721::id(n) makes the ID of serial number n:

src/lib.rs
#![cfg_attr(target_arch = "wasm32", no_std)]
extern crate alloc;

use alloc::string::String;
use ksana_contract_sdk::{self as sdk, ks721::{self, Ks721}, standard, storage, CallResult};

sdk::entry!(dispatch);

const NFT: Ks721 = Ks721::new().burnable().enumerable();

pub fn dispatch(method: &str, args: &[u8]) -> CallResult {
    match method {
        "init" => {
            standard::no_funds()?;
            let (name, symbol): (String, String) = sdk::decode(args)?;
            NFT.init(&name, &symbol)?;
            storage::write(b"minter", &sdk::caller());
            sdk::ok(&())
        }
        "mint" => {
            standard::no_funds()?;
            let (to, serial, uri): ([u8; 32], u128, Option<String>) = sdk::decode(args)?;
            if storage::read::<[u8; 32]>(b"minter")? != Some(sdk::caller()) {
                return sdk::revert("not the minter");
            }
            NFT.mint(&to, &ks721::id(serial), uri.as_deref())?;
            sdk::ok(&())
        }
        _ => NFT.dispatch(method, args).unwrap_or_else(|| sdk::revert("unknown method")),
    }
}

The implementation keeps its state under keys that start with KS721:. A Policy adds the collection’s own rules, a check before every change of owner that can also keep the collection’s records, and conformance::ks721(setup) checks a collection on the mock host as the KS-20 kit checks a token.

The example contracts/examples/nft is a KS-721 collection built this way, burnable and enumerable, with a market of its own: owners list tokens at a fixed price in any built-in asset, and a buyer pays it attached to buy. The playground’s NFT page deploys it. The test collection contracts/testing/collection, burnable and enumerable, deploys for about 842,000 gas (33.6 KB of code), and a mint takes about 169,000, a transfer 176,000 and a transfer_and_call 218,000, measured as KS-20’s are.