Build
KS-20: fungible tokens
The standard for fungible tokens whose balances a contract keeps. Wallets, pools and other contracts read and move every KS-20 token the same way, and transfer_and_call pays a contract and calls it in one operation.
On this page
A KS-20 token is a contract that keeps a ledger of balances in its own storage. The standard fixes its methods, events and refusals, so that a wallet lists any KS-20 token, a pool trades any of them and an indexer counts their transfers from receipts alone. It is a convention between contracts, wallets and indexers, not a consensus rule: the node runs a token like any other contract. The specification is docs/STANDARDS.md in the repository; this page restates it. Assets and tokens compares KS-20 with built-in assets and covers what KS-20 and KS-721 share.
Interface
Arguments and return values are Borsh, in the notation of the playground’s ABI block: view methods change nothing, call methods may, and payable marks the one method that takes attached funds. An Account is 32 bytes, a user’s Taproot output key or a contract ID, and a ContractId is an account that must be a contract.
view standards() -> Vec<String> // ["KS-20", extensions...]
view metadata() -> (String, String, u8) // (name, symbol, decimals)
view capabilities() -> Vec<String> // what the token's rules let someone do now
view total_supply() -> u128
view balance_of(account: Account) -> u128
view allowance(owner: Account, spender: Account) -> u128 // what spender may take now: 0 once expired
view allowance_info(owner: Account, spender: Account) -> (u128, Option<u64>) // (amount, expires_at) as set
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 // returns the recipient's answer
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>)A contract that takes tokens in one step adds payable call on_token_received(from: Account, amount: u128, data: Bytes) -> Bytes. init is not part of the standard: each token takes the arguments it needs.
How a token declares itself
init emits standards, then metadata, before any transfer, and the view standards() returns the same list for the token’s whole life: "KS-20" first, then the extensions it implements, such as "KS-20-burnable". A wallet asks the view; an indexer takes the contract’s first standards event as its declaration and counts its standard events from there on. A declaration is a claim, not a guarantee, so wallets go by a token’s ID and code hash, as Assets and tokens explains.
Rules
- Metadata:
decimalsis at most 18 and never changes;nameis 1 to 64 bytes andsymbol1 to 16 bytes of UTF-8. A token that changes its name or symbol emitsmetadataagain. - Every change is an event: every change of a balance comes with a
transferevent in the same operation, for the amount that changed. A mint is a transfer from the zero account (32 zero bytes) and a burn a transfer to it, andtotal_supplyis the sum of all balances. - Fees come out of the amount: a transfer of
amountlowers the sender’s balance by exactlyamount. A token that charges a fee credits the recipient withamount − feeand emits twotransferevents, to the recipient and to its fee account (the zero account when the fee is burned), and reportsfeeincapabilities(). - No zero amounts: moving 0 reverts with
zero amount;approvewith 0 revokes. - Recipients: never the zero account (
zero account) nor the token itself (recipient is this contract). - Attached funds: every method but
transfer_and_callreverts withunexpected attached funds, so nothing gets stuck in the token’s account. - Allowances:
approvesets the allowance, it does not add to it.transfer_frombyfromitself needs none; anyone else takesamountof the allowancefromgave them, which must not have expired (allowance expired) and must cover it (insufficient allowance).u128::MAXis unlimited and never goes down. - A token’s own rules: a transfer they refuse (a frozen account, a pause, an allowlist) reverts with a reason that starts with
rejected:. - Effects before the call:
transfer_and_callmoves the tokens and emits the events before it calls the recipient.
A transfer beyond the balance reverts with insufficient balance. Assets and tokens lists every reason.
Pay a contract in one operation
transfer_and_call(to, amount, data) sends tokens to a contract and calls it in the same operation. When an account U calls it on the token T and attaches built-in assets F:
- The VM moves F from U to T before T runs.
- T moves
amountfrom U to the contract R by the rules above and emits the events. - T calls
R.on_token_received(U, received, data)with F attached, wherereceivedis what R was credited. - R acts on
data(a swap, a deposit) and answersBytesof its own content, which T returns unchanged: a wallet can show what a swap paid out. - When R is no contract, has no
on_token_received, refuses or fails, the whole operation fails, and the tokens and F stay with U.
So a swap of a token is one operation with one fee and nothing to approve, and a pool of a token and KSA takes liquidity in one operation, the KSA attached to transfer_and_call and passed on by the token. The token and pool tutorial does both.
A receiving contract checks that caller() is a token it accepts: anyone can call on_token_received directly with a made-up from and amount, and only the token itself makes them true. The attached funds are always real, whoever the caller. A contract without on_token_received refuses the call, so tokens sent to it with transfer_and_call stay with the sender, while a plain transfer to a contract that cannot move tokens strands them: wallets suggest transfer_and_call when the recipient is a contract.
Allowances
An allowance with expires_at of None never expires; otherwise it counts while the block timestamp, in seconds, is below expires_at. allowance is what the spender may take now, and allowance_info the amount and the expiry as set, so a wallet can list them and warn. A spender can take up to the amount at any time, so an allowance to a contract that turns out broken can empty the account: wallets approve the amount one operation needs, with an expiry of about a week, ask separately for an unlimited amount or none, and revoke with approve(spender, 0, None). With transfer_and_call, most uses need no allowance at all.
Events
| Event | Fields | When |
|---|---|---|
standards |
ids: Vec<String> |
in init, once |
metadata |
name: String, symbol: String, decimals: u8 |
in init, and again when the name or the symbol changes |
transfer |
from: Account, to: Account, amount: u128 |
every change of a balance: mints from the zero account, burns to it, a fee as a transfer of its own |
approve |
owner: Account, spender: Account, amount: u128, expires_at: Option<u64> |
every approve; expires_at is None when amount is 0 |
An event is the Borsh encoding of a tuple whose first element is its name, and the receipt names the contract that emitted it. A transfer event is 92 bytes: the name (4 bytes of length and 8 of text), then 32, 32 and 16.
Extensions
A token lists the extensions it implements after "KS-20" in standards().
call burn(amount: u128) // burns the caller's tokens
call burn_from(from: Account, amount: u128) // with the allowance from gave the callerview minter() -> Option<Account> // None once minting ended for good
view cap() -> Option<u128> // the most total_supply can reach; None for no cap
call mint(to: Account, amount: u128) // the minter only, up to the cap
call set_minter(minter: Option<Account>) // the minter only; None ends minting for good
event minter(previous: Option<Account>, minter: Option<Account>)A mintable token emits minter in init and whenever the minter changes, and reports mint in capabilities() while it has a minter. mint and set_minter by anyone else revert with not the minter, or minting has ended once there is none, and a mint past the cap with cap exceeded.
Tokens written before KS-20
The example token took approve(spender, amount) and init(name, symbol, decimals, supply) before KS-20, and the reference token still takes both: an approve of two arguments has no expiry, and an init of four has no minter and no cap. A pool that pulls tokens with transfer_from after an approve, as the example AMM still can, works with any KS-20 token.
Build a token
The contract SDK implements KS-20 in its ks20 module, behind the standards feature:
[dependencies]
ksana-contract-sdk = { workspace = true, features = ["standards"] }Ks20::new() is a plain token; .burnable() and .mintable() add the extensions, and .with_policy(rules) the token’s own rules (a check before every movement, a fee, the capabilities it reports). Ks20::dispatch answers every standard method and None for the others, so the token’s own methods go beside it. A burnable token with a fixed supply:
#![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"))
}The implementation keeps its state under storage keys that start with KS20:, so the token’s own state goes under any other key. The example contracts/examples/token is the reference KS-20 token, burnable and mintable, and the playground’s KS-20 token page deploys it. A contract that receives tokens decodes on_token_received with ks20::received, checks the caller, and answers with standard::reply(&value); ks20::Token calls a token from another contract.
The SDK’s conformance::ks20(setup) checks a token on the mock host, whatever its own rules: the declaration, events for every change, the refusals and their reasons, allowances and their expiry, attached funds, transfer_and_call with a receiving contract the kit registers, and the extensions it declares. Call it from the token’s native tests.
What it costs
Measured with the gas table every network uses, in crates/ksana-execution/tests/contracts.rs:
| Operation on the reference token | Gas |
|---|---|
| Deploy (28.7 KB of code) | about 780,000 |
transfer |
about 84,000 |
approve |
about 68,000 |
transfer_from |
about 99,000 |
transfer_and_call to a test receiver |
about 157,000 |
| A built-in asset’s transfer, for comparison | 10,000 |
The deployment fits within the default --max-gas of ksana-cli, 1,000,000. A swap or a deposit through transfer_and_call also pays for the pool’s work: the tutorial shows each one’s gas.