Build
Host functions and gas
The interface between a contract and the node that runs it: how a call enters the module, every host function with its SDK wrapper and its gas, and the limits.
On this page
A contract talks to the node only through host functions: the functions of the WebAssembly import module ksana. With ksana-contract-sdk you call them through Rust wrappers and never touch pointers; this page also gives the raw interface, for tools and for contracts written without the SDK.
The gas costs and limits on this page are consensus rules. They are the same on devnet, the signet and mainnet, and they change only through a protocol upgrade. The ABI in the repository is the full specification.
How a call enters a contract
Every call creates a new instance of the module, so linear memory never carries over from one call to the next. A call runs in three steps:
- The node calls the export
ksana_alloc(len: i32) -> i32to get a buffer, and writes the input into it: the Borsh encoding of(method: String, args: Vec<u8>). - The node calls
ksana_call(ptr: i32, len: i32) -> i64. - The returned
i64, read as unsigned, is(out_ptr << 32) | out_len. It points at the Borsh encoding ofResult<Vec<u8>, Vec<u8>>, whose tag is0forErrand1forOk:Ok(data)returnsdata, andErr(reason)reverts.
These all fail the call: a trap (including unreachable, an access out of bounds and division by zero), running out of gas, output that is not a valid Result encoding, output longer than 65,536 bytes, and Err. A failure at any level fails the whole op, except under try_call_contract.
Host functions take pointers as i32 offsets into the contract’s own memory, and an offset out of bounds traps. Functions that write a fixed-size result take a destination pointer dst. Functions that return data of variable length allocate it by calling the contract’s ksana_alloc and return it packed the same way, (ptr << 32) | len, or -1 when there is nothing. entry! in the SDK generates the two exports and does this decoding for you.
Host functions
The tables group the 24 functions by purpose. “/B” means per byte.
Storage
| Host function | SDK | Gas | Notes |
|---|---|---|---|
storage_get |
storage::get, storage::read |
2,000 + 1/B of key and value | reads this contract’s storage; None when absent |
storage_set |
storage::set, storage::write |
10,000 + 10/B of key and value | keys up to 256 bytes, values up to 65,536 |
storage_remove |
storage::remove |
5,000 + 1/B of key |
Storage is private: a contract reads and writes only its own keys. A write is visible at once to every level of the same op, re-entrant calls included.
Context
| Host function | SDK | Gas | Notes |
|---|---|---|---|
caller |
caller() |
100 | the op’s account at the top level; the calling contract inside a call |
self_id |
self_id() |
100 | this contract’s ID |
block_height |
block_height() |
100 | height of the main-chain block executing the op |
block_timestamp |
block_timestamp() |
100 | that block’s timestamp, in seconds |
chain_id |
chain_id() |
100 | 1001 devnet, 1002 signet, 1000 mainnet |
gas_left |
gas_left() |
100 | gas left after this function’s own cost |
An op in a merged block runs with the height and timestamp of the main-chain block that merges it. The producer sets the timestamp within loose bounds, so it is not a precise clock. A read-only call (the RPC method ksana_call) runs as if in the next block: the tip’s height plus one, with the tip’s timestamp.
/// `env() -> (u64, u64, u32, u32)`: the block a call runs in, as the contract sees it.
pub fn env() -> CallResult {
let height = sdk::block_height(); // the main-chain block executing the op
let time = sdk::block_timestamp(); // seconds, set by the producer: not a precise clock
sdk::ok(&(height, time, sdk::chain_id(), sdk::btc_anchor_height()))
}Events
| Host function | SDK | Gas | Notes |
|---|---|---|---|
emit_event |
emit_event(&[u8]), emit(&T) |
1,000 + 5/B | at most 16,384 bytes |
Events appear in the op’s receipt as { "contract", "data" }. They are not part of the state root, and they are dropped when the op fails.
Assets
| Host function | SDK | Gas | Notes |
|---|---|---|---|
asset_balance |
asset_balance(asset, account) |
2,000 | any account’s or contract’s balance, as a u128 |
asset_transfer |
asset_transfer(asset, to, amount) |
10,000 | pays out of this contract’s balance; traps when it is short |
asset_issue |
asset_issue(sub_id, meta, to, amount) |
20,000 + 1/B of meta |
issues this contract’s own asset; returns its ID |
asset_burn |
asset_burn(asset, amount) |
10,000 | burns from this contract’s balance, KSA included |
call_funds |
call_funds(), received(asset) |
100 + 1/B returned | the assets attached to the current call |
Accounts and contracts share one balance table for built-in assets: KSA (ID: 32 zero bytes, 8 decimals) and the assets contracts issue. An issued asset’s ID is sha256(issuing contract ‖ sub_id), which asset_id(issuer, sub_id) computes. The first issue records meta (a name and decimals, at most 1,024 bytes encoded), and every later issue must pass the same meta, or it traps.
Attached funds move to the contract before it runs. They must be in strictly ascending order of asset ID, each above zero, at most 16 items.
Calls
| Host function | SDK | Gas | Notes |
|---|---|---|---|
call_contract |
call, call_with_funds |
20,000 + 1/B of input + 1/B of callee code + 10,000 per asset | a failure of the callee fails the op |
try_call_contract |
try_call, try_call_with |
as call_contract |
a failure rolls back only the callee |
delegate_call |
delegate_call |
as call_contract, no assets |
runs another contract’s code in this contract’s context |
create_contract |
create_contract |
100,000 + 1/B of args + 1/B of code + 10,000 per asset | creates a contract from code on chain |
try_call_contractreturns(status: u8, data):0with the callee’s output,1with its revert reason,2for any other failure (the SDK maps these toOk,CallError::RevertandCallError::Failed). The callee gets at mostmin(gas, remaining − remaining / 64), where agasof 0 sets no separate limit. The caller’s own mistakes still fail the op: the method nameinit, an input that is too long, a malformed funds list.delegate_callruns the other code with this contract’s storage, balances,self_id,callerandcall_funds. Use it only with code you trust.create_contractgives the new contract the IDsha256(this contract ‖ salt ‖ code_hash)(created_contract_idcomputes it), then runs itsinitwith this contract as the caller. It traps when the code is not on chain, the ID is taken, orinitfails.
The op’s own call counts as level 1, and calls nest at most 8 levels. try_call_contract and delegate_call take a level too. Re-entry (A calls B calls A) is allowed.
Bitcoin
| Host function | SDK | Gas | Notes |
|---|---|---|---|
btc_anchor_height |
btc_anchor_height() |
100 | the Bitcoin height the executing block anchors to |
btc_header |
btc_header(height) |
2,000 | the 80-byte header; traps unless height ≤ anchor − 6 |
native_tx |
native_tx() |
2,000 + 1/B returned | the Bitcoin transaction that carried this path C op |
native_tx returns None for path B ops and for read-only calls. Every level of one op reads the same transaction, so a contract must make sure it counts a payment once. Bitcoin in contracts shows how to use these three, and the SDK’s btc module parses headers and transactions and checks Merkle proofs inside the contract.
Crypto
| Host function | SDK | Gas | Notes |
|---|---|---|---|
sha256 |
sha256(data), sha256d(data) |
500 + 1/B | sha256d is two calls |
verify_schnorr |
verify_schnorr(pubkey, digest, sig) |
50,000 | BIP340 over a 32-byte digest |
verify_schnorr checks a signature of a 32-byte digest, not of a message: hash the message first. A Ksana account is an x-only key, so a contract can check a signature by an account:
/// Whether `sig` is a BIP340 signature by `signer` of `message`; the host checks a 32-byte digest.
pub fn signed_by(signer: &[u8; 32], message: &[u8], sig: &[u8; 64]) -> bool {
sdk::verify_schnorr(signer, &sdk::sha256(message), sig)
}The SDK’s contract_id, created_contract_id and asset_id compute identifiers with sha256 inside the contract, at its gas. trap() executes unreachable, for cases that cannot happen.
Raw signatures
The node accepts these imports, with exactly these types, and no others:
storage_get(key_ptr: i32, key_len: i32) -> i64
storage_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32)
storage_remove(key_ptr: i32, key_len: i32)
caller(dst: i32)
self_id(dst: i32)
block_height() -> i64
block_timestamp() -> i64
chain_id() -> i64
gas_left() -> i64
btc_anchor_height() -> i64
btc_header(height: i64, dst: i32)
asset_balance(asset_ptr: i32, account_ptr: i32, dst: i32)
asset_transfer(asset_ptr: i32, to_ptr: i32, amount_ptr: i32)
asset_issue(sub_id_ptr: i32, meta_ptr: i32, meta_len: i32, to_ptr: i32, amount_ptr: i32, dst: i32)
asset_burn(asset_ptr: i32, amount_ptr: i32)
create_contract(code_hash_ptr: i32, salt_ptr: i32, args_ptr: i32, args_len: i32,
funds_ptr: i32, funds_len: i32, dst: i32)
call_contract(contract_ptr: i32, input_ptr: i32, input_len: i32,
funds_ptr: i32, funds_len: i32) -> i64
call_funds() -> i64
native_tx() -> i64
try_call_contract(contract_ptr: i32, input_ptr: i32, input_len: i32,
funds_ptr: i32, funds_len: i32, gas: i64) -> i64
delegate_call(contract_ptr: i32, input_ptr: i32, input_len: i32) -> i64
emit_event(ptr: i32, len: i32)
sha256(ptr: i32, len: i32, dst: i32)
verify_schnorr(pubkey_ptr: i32, msg_ptr: i32, sig_ptr: i32) -> i32Amounts are 16-byte little-endian u128 values in memory. funds is a Borsh Vec<Funds { asset: [u8; 32], amount: u128 }>, at most 772 bytes, with funds_len = 0 for none. The input of a call is encoded as in How a call enters a contract. verify_schnorr returns 1 for a valid signature and 0 otherwise.
Gas
Gas is wasmtime fuel: ordinary instructions cost 1, while nop, drop and the structured control instructions cost 0. memory.copy, memory.fill and memory.init cost 1 more per byte, memory.grow 1 per page, and table operations 1 per element. Host functions add the costs in the tables above.
Each op also pays a fixed cost for its action:
| Action | Gas |
|---|---|
AssetTransfer |
10,000 |
Deploy |
100,000 + 20/B of code + 10,000 per attached asset + init, which includes loading the code at 1/B |
Call |
20,000 + 1/B of the contract’s code + 10,000 per attached asset + the method |
The op’s max_gas is a hard limit: an op that needs more fails, even when the excess comes at the very end. A failed op’s receipt reports all of max_gas as used. The max_gas values of a block’s path B ops add up to at most 100,000,000, so no op can ask for more; a path C op with a max_gas above 1,000,000 uses its nonce but fails.
On the public signet, these ops of the token and pool tutorial used:
| Op | Gas |
|---|---|
| Deploy the 11,572-byte token | 383,868 |
| Deploy the 20,768-byte pool | 574,872 |
approve on the token |
48,026 |
add_liquidity, KSA attached |
182,212 |
swap, KSA attached |
144,458 |
Gas sets the limit of an op’s work; it is not its price. A path B fee is paid in KSA and rated by the op’s cost in bytes, where max_gas counts as max_gas × 81,920 / 100,000,000 bytes. Operations and fees explains how producers choose.
Limits
| Limit | Value |
|---|---|
| Storage key / value | 256 / 65,536 bytes |
| Event | 16,384 bytes |
asset_issue meta |
1,024 bytes |
| Call input / output | 65,536 / 65,536 bytes |
| Attached funds | 16 items, 772 bytes encoded |
Call depth (MAX_CALL_DEPTH) |
8 |
| Instances / total memory in one op | 64 / 256 MiB |
Linear memory of an instance (MAX_WASM_MEMORY) |
16 MiB, at most 256 pages at the start |
| Table | 4,096 entries |
Stack of an instance (STACK_BUDGET) |
262,144 bytes, estimated |
Whole op, code included (MAX_OP_BYTES) |
65,536 bytes |
max_gas of a block’s path B ops (BLOCK_GAS_LIMIT) |
100,000,000 in total |
max_gas of a path C op (NATIVE_MAX_GAS) |
1,000,000 |
Header depth (BTC_QUERY_DEPTH) |
6 Bitcoin blocks below the anchor |
When memory.grow would pass 16 MiB it returns −1, and the SDK’s allocator then traps. Breaking any other limit fails the call or the op.
Stack metering
The native stack depth of WebAssembly differs between machines, so the node meters the stack with an estimate that is the same everywhere. A function’s frame counts as 64 + 16 × (parameters + locals + the deepest operand stack) bytes, and an instance that goes past 262,144 bytes traps. Each contract call is a new instance with its own budget. Recursion, function pointers, trait objects and core::fmt all work within it.