开发
KS-20:同质化代币
由合约记录余额的同质化代币的标准。钱包、资金池和其他合约以同样的方式读取和转移每一个 KS-20 代币,transfer_and_call 在一个操作里付款给一个合约并调用它。
KS-20 代币是一个在自己的存储里记一本余额账的合约。标准固定了它的方法、事件和拒绝原因,让钱包能列出任何 KS-20 代币,资金池能交易其中任何一种,索引器只凭回执就能统计它们的转账。它是合约、钱包和索引器之间的约定,不是共识规则:节点像运行其他合约一样运行代币。规范是仓库里的 docs/STANDARDS.md;本页重述它。资产与代币把 KS-20 和内置资产作了比较,并介绍 KS-20 与 KS-721 的共同之处。
接口
参数和返回值都是 Borsh 编码,用 Playground 的 ABI 块的写法:view 方法什么也不改变,call 方法可能会改变,payable 标出唯一接受附带资金的方法。Account 是 32 字节,即用户的 Taproot 输出公钥或一个合约 ID;ContractId 是必须为合约的账户。
view standards() -> Vec<String> // ["KS-20", 扩展...]
view metadata() -> (String, String, u8) // (名称, 符号, 精度)
view capabilities() -> Vec<String> // 代币的规则此刻允许别人做什么
view total_supply() -> u128
view balance_of(account: Account) -> u128
view allowance(owner: Account, spender: Account) -> u128 // spender 此刻能拿走多少:过期后为 0
view allowance_info(owner: Account, spender: Account) -> (u128, Option<u64>) // 设定的 (数额, expires_at)
call transfer(to: Account, amount: u128)
call transfer_from(from: Account, to: Account, amount: u128)
call approve(spender: Account, amount: u128, expires_at: Option<u64>)
payable call transfer_and_call(to: ContractId, amount: u128, data: Bytes) -> Bytes // 返回收款方的返回值
event standards(ids: Vec<String>)
event metadata(name: String, symbol: String, decimals: u8)
event transfer(from: Account, to: Account, amount: u128)
event approve(owner: Account, spender: Account, amount: u128, expires_at: Option<u64>)想一步收取代币的合约,加上 payable call on_token_received(from: Account, amount: u128, data: Bytes) -> Bytes。init 不属于标准:每个代币接受它自己需要的参数。
代币怎样声明自己
init 先发出 standards,再发出 metadata,都在任何 transfer 之前;只读方法 standards() 在代币的整个生命周期里都返回同一个列表:先是 "KS-20",然后是它实现的扩展,比如 "KS-20-burnable"。钱包调用这个只读方法;索引器把合约的第一个 standards 事件当作它的声明,并从那里开始统计它的标准事件。声明只是一种主张,不是保证,所以钱包依据代币的 ID 和代码哈希,资产与代币解释了原因。
规则
- 元数据:
decimals至多为 18,且永不改变;name是 1 到 64 字节、symbol是 1 到 16 字节的 UTF-8。改变名称或符号的代币要再次发出metadata。 - 每次变化都有事件:余额的每一次变化,都在同一个操作里伴随一个
transfer事件,数额就是变化的数额。增发是从零账户(32 个零字节)发出的转账,销毁是转给零账户的转账,total_supply是所有余额之和。 - 手续费从数额里扣:一笔
amount的转账,让发送者的余额恰好减少amount。收手续费的代币给收款方记入amount − fee,发出两个transfer事件,一个给收款方,一个给它的手续费账户(手续费销毁时是零账户),并在capabilities()里报告fee。 - 没有零数额:转移 0 会以
zero amountrevert;approve为 0 表示撤销。 - 收款方:永远不能是零账户(
zero account),也不能是代币自身(recipient is this contract)。 - 附带的资金:除
transfer_and_call外,每个方法都以unexpected attached fundsrevert,这样就没有东西会卡在代币的账户里。 - 授权额度:
approve设定授权额度,而不是在原有额度上累加。from自己调用transfer_from不需要授权额度;其他人调用时,要从from给他们的授权额度里扣除amount,这个额度必须没有过期(allowance expired),并且足够(insufficient allowance)。u128::MAX表示不限,而且永远不会减少。 - 代币自己的规则:它们拒绝的转账(冻结的账户、暂停、白名单)以
rejected:开头的原因 revert。 - 先生效,再调用:
transfer_and_call先转移代币、发出事件,再调用收款方。
超出余额的转账以 insufficient balance revert。资产与代币列出了每一个原因。
一个操作付款给合约
transfer_and_call(to, amount, data) 在同一个操作里把代币发给一个合约并调用它。当账户 U 对代币 T 调用它,并附带内置资产 F 时:
- VM 在 T 运行之前,把 F 从 U 转给 T。
- T 按上面的规则把
amount从 U 转给合约 R,并发出事件。 - T 附带 F 调用
R.on_token_received(U, received, data),其中received是 R 实际记入的数额。 - R 按
data行事(一笔兑换、一笔存入),返回内容由它自己决定的Bytes,T 原样返回:钱包可以显示一笔兑换换出了多少。 - 如果 R 不是合约、没有
on_token_received、拒绝或者失败,整个操作就失败,代币和 F 都留在 U 那里。
所以兑换一个代币是一个操作,只付一笔手续费,什么也不用授权;代币与 KSA 组成的资金池,用一个操作就能收下流动性:KSA 附带在 transfer_and_call 上,由代币转交过去。代币与资金池教程把两件事都做了一遍。
接收代币的合约要检查 caller() 是不是它接受的代币:任何人都可以直接调用 on_token_received,编造 from 和 amount,只有代币自己调用时它们才是真的。附带的资金总是真的,不管调用者是谁。没有 on_token_received 的合约会拒绝这次调用,所以用 transfer_and_call 发给它的代币会留在发送者那里;而用普通的 transfer 转给一个无法转出代币的合约,代币就卡在那里了:收款方是合约时,钱包会建议用 transfer_and_call。
授权额度
expires_at 为 None 的授权额度永不过期;否则,它在区块时间戳(以秒计)低于 expires_at 时有效。allowance 是被授权方此刻能拿走的数额,allowance_info 是设定的数额和有效期,钱包可以据此列出它们并发出提醒。被授权方随时可以拿走额度以内的代币,所以授权给一个后来发现有问题的合约,可能会让账户被清空:钱包只授权一次操作需要的数额,有效期约一周,不限数额或不设有效期要另外征得同意,并用 approve(spender, 0, None) 撤销。有了 transfer_and_call,大多数用途根本不需要授权额度。
事件
| 事件 | 字段 | 何时发出 |
|---|---|---|
standards |
ids: Vec<String> |
在 init 里,一次 |
metadata |
name: String, symbol: String, decimals: u8 |
在 init 里,以及名称或符号改变时 |
transfer |
from: Account, to: Account, amount: u128 |
余额的每一次变化:增发从零账户发出,销毁转给零账户,手续费单独作为一笔转账 |
approve |
owner: Account, spender: Account, amount: u128, expires_at: Option<u64> |
每次 approve;amount 为 0 时 expires_at 为 None |
事件是一个元组的 Borsh 编码,元组的第一个元素是事件名,回执会写明发出它的合约。一个 transfer 事件是 92 字节:名称(4 字节长度加 8 字节文本),然后是 32、32 和 16 字节。
扩展
代币在 standards() 里把它实现的扩展列在 "KS-20" 之后。
call burn(amount: u128) // 销毁调用者的代币
call burn_from(from: Account, amount: u128) // 用 from 给调用者的授权额度view minter() -> Option<Account> // 永久结束增发后为 None
view cap() -> Option<u128> // total_supply 最多能达到多少;None 表示无上限
call mint(to: Account, amount: u128) // 仅限增发者,以上限为限
call set_minter(minter: Option<Account>) // 仅限增发者;None 永久结束增发
event minter(previous: Option<Account>, minter: Option<Account>)可增发的代币在 init 里以及每次增发者改变时发出 minter,有增发者期间在 capabilities() 里报告 mint。其他人调用 mint 和 set_minter 会以 not the minter revert,没有增发者之后以 minting has ended revert;增发超过上限以 cap exceeded revert。
KS-20 之前写的代币
在 KS-20 之前,示例代币接受 approve(spender, amount) 和 init(name, symbol, decimals, supply),参考代币两者都仍然接受:两个参数的 approve 没有有效期,四个参数的 init 没有增发者,也没有上限。在 approve 之后用 transfer_from 拉入代币的资金池,比如示例 AMM 仍然可以这样做,能和任何 KS-20 代币配合。
构建一个代币
合约 SDK 在它的 ks20 模块里实现了 KS-20,放在 standards feature 后面:
[dependencies]
ksana-contract-sdk = { workspace = true, features = ["standards"] }Ks20::new() 是一个普通代币;.burnable() 和 .mintable() 加上扩展,.with_policy(rules) 加上代币自己的规则(每次转移之前的检查、手续费、它报告的能力)。Ks20::dispatch 回应每一个标准方法,对其他方法返回 None,所以代币自己的方法放在它旁边。一个固定总量、可销毁的代币:
#![cfg_attr(target_arch = "wasm32", no_std)]
extern crate alloc;
use alloc::string::String;
use ksana_contract_sdk::{self as sdk, ks20::Ks20, standard, CallResult};
sdk::entry!(dispatch);
const TOKEN: Ks20 = Ks20::new().burnable();
pub fn dispatch(method: &str, args: &[u8]) -> CallResult {
if method == "init" {
standard::no_funds()?;
let (name, symbol, decimals, supply): (String, String, u8, u128) = sdk::decode(args)?;
TOKEN.init(&name, &symbol, decimals)?;
TOKEN.mint(&sdk::caller(), supply)?;
return sdk::ok(&());
}
TOKEN.dispatch(method, args).unwrap_or_else(|| sdk::revert("unknown method"))
}实现把它的状态放在以 KS20: 开头的存储键下,所以代币自己的状态可以放在任何其他键下。示例 contracts/examples/token 是参考 KS-20 代币,可销毁、可增发,Playground 的 KS-20 代币页面部署的就是它。接收代币的合约用 ks20::received 解码 on_token_received,检查调用者,用 standard::reply(&value) 作答;在另一个合约里,ks20::Token 用来调用代币。
SDK 的 conformance::ks20(setup) 在模拟宿主上检查一个代币,不论它有什么自己的规则:声明、每次变化的事件、拒绝及其原因、授权额度及其有效期、附带的资金、配合测试套件注册的接收合约的 transfer_and_call,以及它声明的扩展。在代币的原生测试里调用它。
花费多少
用每个网络都使用的 gas 表,在 crates/ksana-execution/tests/contracts.rs 里测得:
| 对参考代币的操作 | Gas |
|---|---|
| 部署(28.7 KB 代码) | 约 780,000 |
transfer |
约 84,000 |
approve |
约 68,000 |
transfer_from |
约 99,000 |
向一个测试接收合约 transfer_and_call |
约 157,000 |
| 内置资产的一次转账,作为对比 | 10,000 |
部署在 ksana-cli 默认的 --max-gas 1,000,000 以内。通过 transfer_and_call 做的兑换或存入,还要为资金池的工作付费:教程列出了每一步的 gas。