开发
KS-721:非同质化代币
由独一无二的代币组成的合集的标准,每个代币有一个 32 字节的 ID 和一个所有者。钱包、市场和其他合约以同样的方式读取和转移每一个 KS-721 代币。
内置资产是同质化的数额,所以 VM 说不出某件物品归谁:合集是一个在自己的存储里记录每个代币所有者的合约。KS-721 固定了它的方法、事件和拒绝原因,让钱包能显示任何合集的代币,市场能出售其中任何一个。和 KS-20 一样,它是合约、钱包和索引器之间的约定,不是共识规则,规范在 docs/STANDARDS.md;资产与代币介绍了两者的共同之处。
接口
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。
规则
- 声明:
init先发出standards,再发出metadata,都在任何transfer之前;standards()以"KS-721"开头。一个合约永远不会同时声明 KS-20 和 KS-721。 - 每次换手都有事件:每次铸造、转让和销毁都发出
transfer,铸造从零账户发出,销毁转给零账户;balance_of是一个账户持有的代币个数。 - 授权随所有者结束:所有者变了,代币被授权的账户就被清除,这不单独发出事件。
- 谁能转移代币:
transfer和transfer_and_call只属于所有者(not the owner)。transfer_from可以由所有者、被授权的账户或操作员调用(not authorized),而且from此刻必须拥有这个代币(wrong owner),这样市场里的旧挂单就卖不掉所有者已转到另一个钱包的代币。 - 收款方:既不能是零账户(
zero account),也不能是合集自身(recipient is this contract),也不能是代币的所有者(already the owner)。 - ID 只铸造一次:某个代币正在使用(
token exists)或在销毁之前用过(token was burned)的 ID,永远不会再被铸造。 - 附带的资金:除
transfer_and_call外,每个方法都以unexpected attached fundsrevert。 - 授权与操作员:被授权的账户既不能是零账户,也不能是所有者(
spender is the owner)。操作员可以转移、授权和销毁所有者的每一个代币,包括现有的和以后得到的,只要区块时间戳低于expires_at(为None时一直有效);approved为 false 的set_operator会收回它。 - 未知的代币:方法指向一个不存在的代币时,以
unknown tokenrevert;只读方法返回None。 - 合集自己的规则以
rejected:开头的原因拒绝。 - 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:所有代币的) |
扩展
call burn(token_id: [u8; 32]) // 所有者、被授权的账户或操作员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:
#![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 的相同。