Ksana
EN

开发

KS-721:非同质化代币

由独一无二的代币组成的合集的标准,每个代币有一个 32 字节的 ID 和一个所有者。钱包、市场和其他合约以同样的方式读取和转移每一个 KS-721 代币。

本页目录

内置资产是同质化的数额,所以 VM 说不出某件物品归谁:合集是一个在自己的存储里记录每个代币所有者的合约。KS-721 固定了它的方法、事件和拒绝原因,让钱包能显示任何合集的代币,市场能出售其中任何一个。和 KS-20 一样,它是合约、钱包和索引器之间的约定,不是共识规则,规范在 docs/STANDARDS.md;资产与代币介绍了两者的共同之处。

接口

KS-721
view standards() -> Vec<String>                                       // ["KS-721", 扩展...]
view metadata() -> (String, String)                                   // (名称, 符号)
view capabilities() -> Vec<String>
view owner_of(token_id: [u8; 32]) -> Option<Account>                  // 代币不存在或已销毁时为 None
view balance_of(owner: Account) -> u64                                // owner 持有多少个代币
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                    // 此刻:过期后为 false
view operator_info(owner: Account, operator: Account) -> (bool, Option<u64>)   // 设定的 (approved, expires_at)
call transfer(to: Account, token_id: [u8; 32])                        // 仅限所有者
call transfer_from(from: Account, to: Account, token_id: [u8; 32])    // 所有者、被授权的账户或操作员
call approve(spender: Option<Account>, token_id: [u8; 32])            // 所有者或操作员;None 表示清除
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   // 仅限所有者
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]>)

想一步收取代币的合约,加上 payable call on_nft_received(from: Account, token_id: [u8; 32], data: Bytes) -> Bytes。铸造和 init 一样,是合集自己的方法:各个合集给代币编号的方式不同,所以标准不包括它,还能铸造的合集会在 capabilities() 里报告 mint。

规则

  1. 声明:init 先发出 standards,再发出 metadata,都在任何 transfer 之前;standards() 以 "KS-721" 开头。一个合约永远不会同时声明 KS-20 和 KS-721。
  2. 每次换手都有事件:每次铸造、转让和销毁都发出 transfer,铸造从零账户发出,销毁转给零账户;balance_of 是一个账户持有的代币个数。
  3. 授权随所有者结束:所有者变了,代币被授权的账户就被清除,这不单独发出事件。
  4. 谁能转移代币:transfer 和 transfer_and_call 只属于所有者(not the owner)。transfer_from 可以由所有者、被授权的账户或操作员调用(not authorized),而且 from 此刻必须拥有这个代币(wrong owner),这样市场里的旧挂单就卖不掉所有者已转到另一个钱包的代币。
  5. 收款方:既不能是零账户(zero account),也不能是合集自身(recipient is this contract),也不能是代币的所有者(already the owner)。
  6. ID 只铸造一次:某个代币正在使用(token exists)或在销毁之前用过(token was burned)的 ID,永远不会再被铸造。
  7. 附带的资金:除 transfer_and_call 外,每个方法都以 unexpected attached funds revert。
  8. 授权与操作员:被授权的账户既不能是零账户,也不能是所有者(spender is the owner)。操作员可以转移、授权和销毁所有者的每一个代币,包括现有的和以后得到的,只要区块时间戳低于 expires_at(为 None 时一直有效);approved 为 false 的 set_operator 会收回它。
  9. 未知的代币:方法指向一个不存在的代币时,以 unknown token revert;只读方法返回 None。
  10. 合集自己的规则以 rejected: 开头的原因拒绝。
  11. URI:代币铸造之后改变它的 URI,要发出 uri(Some(token_id));改变所有代币的 URI(换一个新的基础 URI),要发出 uri(None),让钱包重新读取。

transfer_and_call 的用法和 KS-20 的一样:所有者在合集上调用它,合集把代币转给合约 to,附带资金调用它的 on_nft_received(owner, token_id, data),并返回它的返回值;收款方拒绝或失败时,整个操作失败,代币留在所有者那里。收款方要检查 caller() 是不是它接受的合集。

代币 ID 与 URI

代币 ID 是 32 字节,和账户或交易 ID 一样长,所以可以放一个哈希:名称服务可以用名称的 SHA-256,跟随比特币交易的合集可以用交易的 ID。序号 n 以大端序写在最后 16 字节里,这样存储键按序号排序;前 16 字节为零的 ID,钱包显示为 #n。

token_uri 指向一份 ERC-721 元数据格式的 JSON 文档(name、description、image、attributes),用 data:、https:、ipfs: 或 ar: URI;在那里找不到 JSON 时,钱包把 URI 当作图片或文本显示。存在链上的 data: URI 受存储值 64 KiB 上限的约束,参考实现接受至多 2,048 字节的 URI。钱包只用 <img> 显示图片,SVG 也一样,远程图片通过代理加载,或者等你打开时才加载。

事件

事件 字段 何时发出
standards ids: Vec<String> 在 init 里,一次
metadata name: String, symbol: String 在 init 里,以及名称或符号改变时
transfer from: Account, to: Account, token_id: [u8; 32] 每次铸造、转让和销毁
approve owner: Account, spender: Option<Account>, token_id: [u8; 32] 每次 approve;None 表示清除
operator owner: Account, operator: Account, approved: bool, expires_at: Option<u64> 每次 set_operator
uri token_id: Option<[u8; 32]> 代币铸造之后它的 URI 改变了(None:所有代币的)

扩展

KS-721-burnable
call burn(token_id: [u8; 32])                    // 所有者、被授权的账户或操作员
KS-721-enumerable
view total_supply() -> u64                                            // 存在多少个代币
view tokens(start: u64, limit: u32) -> Vec<[u8; 32]>                  // 所有代币,从位置 start 起的一页
view tokens_of(owner: Account, start: u64, limit: u32) -> Vec<[u8; 32]>   // owner 的代币,一页

一页最多 100 个 ID,start 超过末尾时得到空的一页。维护这些列表,在每次铸造、转让和销毁时都要花 gas,所以这个扩展是可选的:没有它,钱包也能从节点的索引里列出一个持有者的代币。

构建一个合集

合约 SDK 在它的 ks721 模块里实现了 KS-721,和 KS-20 放在同一个 standards feature 后面。带 .burnable() 和 .enumerable() 的 Ks721::new() 在 Ks721::dispatch 里回应每一个标准方法;合集加上它自己的 init 和 mint,ks721::id(n) 生成序号 n 的 ID:

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")),
    }
}

实现把它的状态放在以 KS721: 开头的键下。Policy 加上合集自己的规则,即每次换手之前的检查,它也可以记录合集自己的数据;conformance::ks721(setup) 在模拟宿主上检查一个合集,就像 KS-20 的测试套件检查代币那样。

示例 contracts/examples/nft 就是这样构建的一个 KS-721 合集,可销毁、可枚举,并带有自己的市场:所有者以任意内置资产标出固定价格挂单出售代币,买方把价款附带在 buy 上支付。Playground 的 NFT 页面部署的就是它。测试合集 contracts/testing/collection,可销毁、可枚举,部署约花 842,000 gas(33.6 KB 代码),一次铸造约 169,000,一次转让 176,000,一次 transfer_and_call 218,000,测量方式和 KS-20 的相同。