Ksana
中文

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.

KS-20
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

  1. Metadata: decimals is at most 18 and never changes; name is 1 to 64 bytes and symbol 1 to 16 bytes of UTF-8. A token that changes its name or symbol emits metadata again.
  2. Every change is an event: every change of a balance comes with a transfer event 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, and total_supply is the sum of all balances.
  3. Fees come out of the amount: a transfer of amount lowers the sender’s balance by exactly amount. A token that charges a fee credits the recipient with amount − fee and emits two transfer events, to the recipient and to its fee account (the zero account when the fee is burned), and reports fee in capabilities().
  4. No zero amounts: moving 0 reverts with zero amount; approve with 0 revokes.
  5. Recipients: never the zero account (zero account) nor the token itself (recipient is this contract).
  6. Attached funds: every method but transfer_and_call reverts with unexpected attached funds, so nothing gets stuck in the token’s account.
  7. Allowances: approve sets the allowance, it does not add to it. transfer_from by from itself needs none; anyone else takes amount of the allowance from gave them, which must not have expired (allowance expired) and must cover it (insufficient allowance). u128::MAX is unlimited and never goes down.
  8. A token’s own rules: a transfer they refuse (a frozen account, a pause, an allowlist) reverts with a reason that starts with rejected: .
  9. Effects before the call: transfer_and_call moves 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:

  1. The VM moves F from U to T before T runs.
  2. T moves amount from U to the contract R by the rules above and emits the events.
  3. T calls R.on_token_received(U, received, data) with F attached, where received is what R was credited.
  4. R acts on data (a swap, a deposit) and answers Bytes of its own content, which T returns unchanged: a wallet can show what a swap paid out.
  5. 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().

KS-20-burnable
call burn(amount: u128)                          // burns the caller's tokens
call burn_from(from: Account, amount: u128)      // with the allowance from gave the caller
KS-20-mintable
view 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:

Cargo.toml
[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:

src/lib.rs
#![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.