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.
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
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
- Declaration:
initemitsstandards, thenmetadata, before anytransfer, andstandards()starts with"KS-721". A contract never declares both KS-20 and KS-721. - 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, andbalance_ofis the number of tokens an account holds. - Approvals end with the owner: a change of owner clears the token’s approved account, with no event of its own.
- Who moves a token:
transferandtransfer_and_callare the owner’s alone (not the owner).transfer_fromis the owner’s, the approved account’s or an operator’s (not authorized), andfrommust own the token now (wrong owner), so a market’s old listing cannot sell a token its owner moved to another wallet. - Recipients: neither the zero account (
zero account) nor the collection itself (recipient is this contract), nor the token’s owner (already the owner). - 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. - Attached funds: every method but
transfer_and_callreverts withunexpected attached funds. - 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 belowexpires_at(always forNone);set_operatorwithapprovedfalse takes it back. - Unknown tokens: a method naming a token that does not exist reverts with
unknown token; the views answerNone. - A collection’s own rules refuse with a reason that starts with
rejected:. - 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
call burn(token_id: [u8; 32]) // the owner, the approved account or an operatorview 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 pageA 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:
#![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.