Ksana
中文

Build

Tutorial: a token and a pool

Deploy the example token on the public signet, open a pool that trades it against KSA, add liquidity and swap both ways, all with ksana-cli.

On this page

In this tutorial you deploy three of the example contracts and use them from the command line:

  • token, a fungible token whose balances live in the contract’s own storage;
  • amm, a constant-product pool with your token on one side and KSA on the other;
  • amm-factory, a contract that creates pools at IDs you can compute in advance.

Every command and output on this page comes from one run on the public signet on 14 October 2026. Your IDs, hashes and amounts will differ. Signet coins have no value, and mainnet has not launched: run this on the signet.

Before you start

You need:

  • ksana-cli on your PATH, built from the repository as in the quickstart (ksana-cli describes every command);
  • the repository’s Rust toolchain with the wasm32-unknown-unknown target: rustup toolchain install, run inside the clone, installs both from rust-toolchain.toml;
  • bash, python3 and jq, for the helper functions below;
  • about 51 KSA on the signet. The faucet’s 100 KSA is enough, and the liquidity you remove comes back to you.

Run every command from the root of your clone, in one bash session: later steps use the shell variables that earlier steps set. Each op costs a fee of 1 base unit here.

Build the contracts

The contracts are a separate Cargo workspace in contracts/. Build each one from inside that directory, as a cdylib alone, so that contracts/.cargo/config.toml applies and the module stays small:

Shell
cd contracts
for p in token amm amm-factory; do
  cargo rustc --release --locked --target wasm32-unknown-unknown --package $p --crate-type cdylib
done
cd ..
W=contracts/target/wasm32-unknown-unknown/release
wc -c $W/token.wasm $W/amm.wasm $W/amm_factory.wasm
Output
11572 contracts/target/wasm32-unknown-unknown/release/token.wasm
20768 contracts/target/wasm32-unknown-unknown/release/amm.wasm
 9598 contracts/target/wasm32-unknown-unknown/release/amm_factory.wasm
41938 total

The module bytes depend on the absolute path of your checkout, so your code hashes, and with them your contract IDs, differ from the ones below.

Load the helpers

ksana-cli takes contract arguments as the hex of their Borsh encoding and does no encoding for you. Save these functions as helpers.sh:

helpers.sh
# Borsh values for --args and --init-args, contract IDs, and short receipts (python3, jq)
enc_u128() { python3 -c 'import sys; print(int(sys.argv[1]).to_bytes(16, "little").hex())' "$1"; }
enc_string() {
  python3 -c 'import sys; b = sys.argv[1].encode()
print(len(b).to_bytes(4, "little").hex() + b.hex())' "$1"
}
dec_u128() {  # one or more u128 values, back to back
  python3 -c 'import sys; b = bytes.fromhex(sys.argv[1])
print(*(int.from_bytes(b[i:i + 16], "little") for i in range(0, len(b), 16)))' "$1"
}
code_hash() {
  python3 -c 'import hashlib, sys
print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$1"
}
contract_id() {  # sha256(account + nonce as u64 little-endian + sha256(code))
  python3 -c 'import hashlib, sys
code = hashlib.sha256(open(sys.argv[3], "rb").read()).digest()
seed = bytes.fromhex(sys.argv[1]) + int(sys.argv[2]).to_bytes(8, "little") + code
print(hashlib.sha256(seed).hexdigest())' "$@"
}
brief() {  # the parts of a --wait result this tutorial reads
  jq '{nonce, op_id, status: .status.status, block: .status.block.height,
    gas_used: .status.receipt.gas_used, error: .status.receipt.error,
    output: .status.receipt.output}' "$@"
}

Load them, point the CLI at the signet node, and keep the ID of KSA (32 zero bytes) at hand:

Shell
source ./helpers.sh
export KSANA_RPC=https://signet.ksana.network/rpc
KSA=0000000000000000000000000000000000000000000000000000000000000000

How arguments are encoded, below the steps, explains the bytes the helpers produce.

Make a key and get KSA

Create a key file, keep your account in ACCOUNT, and send your address to the faucet:

Shell
ksana-cli --chain signet key alice.key
ACCOUNT=$(ksana-cli --chain signet key alice.key | jq -r .account)
curl -sS https://signet.ksana.network/faucet -H 'content-type: application/json' \
  --data '{"to": "tb1p6z9g9hz82q62tuvtkkymaq6rguhx0xttsvrkfzyme0k39cj9zzlsk7pwe9"}' | jq
Output
{
  "account": "d08a82dc475034a5f18bb589be8343472e67996b830764889bcbed12e24510bf",
  "address": "tb1p6z9g9hz82q62tuvtkkymaq6rguhx0xttsvrkfzyme0k39cj9zzlsk7pwe9",
  "internal_key": "14e52f83357e9f7ebc1e5939036475473208acd8561431b64d7a56d512ca9560",
  "script": "5120d08a82dc475034a5f18bb589be8343472e67996b830764889bcbed12e24510bf"
}
{
  "op_id": "cdfbd418ad44c47c8fe5a31ba885e9bf1de6c13b5a467e79f4b2b735fc54be97",
  "to": "d08a82dc475034a5f18bb589be8343472e67996b830764889bcbed12e24510bf",
  "amount": "10000000000",
  "nonce": "5"
}

Put your own address in the faucet request. The faucet sends 100 KSA (10,000,000,000 base units) once a day per account; ksana-cli account @alice.key shows the balance a few seconds later. If you already hold signet KSA, skip the faucet.

Deploy the token

The token’s init takes (name: String, symbol: String, decimals: u8, supply: u128) and gives the whole supply to the deployer. This run creates 1,000,000 TUT with 8 decimals:

Shell
INIT=$(enc_string "Tutorial Token")$(enc_string TUT)08$(enc_u128 100000000000000)
echo $INIT
ksana-cli deploy --key alice.key --code $W/token.wasm --init-args $INIT \
  --fee-token 1 --wait | tee token.json | brief
Output
0e0000005475746f7269616c20546f6b656e030000005455540800407a10f35a00000000000000000000
{
  "nonce": "0",
  "op_id": "6daa9848433a349d8f915102679c3cd8632f351c3ee4a17207a58839158dda99",
  "status": "fast_confirmed",
  "block": "264",
  "gas_used": "383868",
  "error": null,
  "output": ""
}

--fee-token 1 sends the op through path B with a fee of 1 base unit, the default packing floor of a node, which the signet’s seed nodes keep. Run ksana-cli estimate-fee to see what your node suggests. --wait polls until a block includes the op: fast_confirmed is the first of its three marks. The whole result, with the receipt’s events, stays in token.json.

Find the token’s ID

Nothing reports a new contract’s ID: you compute it. It is the SHA-256 of your account, the nonce of the deploy op as a little-endian u64, and the SHA-256 of the code:

Shell
TOKEN=$(contract_id $ACCOUNT $(jq -r .nonce token.json) $W/token.wasm)
echo $TOKEN
jq -r '.status.receipt.events[0].contract' token.json
Output
f622bdcea40708c8776b36dbe387e7285b57f8c3734d370a31b63c5c10756740
f622bdcea40708c8776b36dbe387e7285b57f8c3734d370a31b63c5c10756740

The second line is a cross-check: the token’s init emits a transfer event, and every event names the contract that emitted it. Now read the token back. query runs a method on the node without submitting anything, so it costs no fee:

Shell
ksana-cli query --contract $TOKEN --method metadata
ksana-cli query --contract $TOKEN --method balance_of --args $ACCOUNT | jq -r .output
Output
{
  "error": null,
  "events": [],
  "gas_used": "37780",
  "op_id": null,
  "output": "0e0000005475746f7269616c20546f6b656e0300000054555408",
  "success": true
}
00407a10f35a00000000000000000000

The first output is (name, symbol, decimals) in Borsh. The balance is a u128: dec_u128 00407a10f35a00000000000000000000 prints 100000000000000, the whole supply.

Deploy a pool

A pool’s init takes its two sides. Each side is one byte for its kind (00 for a token contract, 01 for a built-in asset such as KSA) followed by a 32-byte ID. Side A is your token and side B is KSA:

Shell
ksana-cli deploy --key alice.key --code $W/amm.wasm --init-args 00${TOKEN}01${KSA} \
  --fee-token 1 --wait | tee pool.json | brief
POOL=$(contract_id $ACCOUNT $(jq -r .nonce pool.json) $W/amm.wasm)
echo $POOL
Output
{
  "nonce": "1",
  "op_id": "fe894004a8b65f507112a395ef01b9648c399b0471f9af98b621d85d5d625ce2",
  "status": "fast_confirmed",
  "block": "266",
  "gas_used": "574872",
  "error": null,
  "output": ""
}
2d6e5edc46352b2931657479fce65fdbf01aafc5acc3de7c27660ce19dda8171

The pool’s methods take amounts in side order: TUT first, then KSA.

Approve the pool

The pool pulls TUT from you with the token’s transfer_from, so first allow it to spend some. approve takes (spender: [u8; 32], amount: u128) and sets the allowance to amount; it does not add to it:

Shell
ksana-cli call --key alice.key --contract $TOKEN --method approve \
  --args $POOL$(enc_u128 10000000000000) --fee-token 1 --wait | tee approve.json | brief
Output
{
  "nonce": "2",
  "op_id": "74d05565eccaca72a833327ce51d1046f7f485aa7d4f804428c1e47ea26dfdca",
  "status": "fast_confirmed",
  "block": "267",
  "gas_used": "48026",
  "error": null,
  "output": ""
}

Add liquidity

add_liquidity takes (amount_a: u128, amount_b: u128). KSA is a built-in asset, so you attach it to the call with --fund, and the attached amount must equal amount_b. This deposit puts 100,000 TUT and 50 KSA into the pool:

Shell
ksana-cli call --key alice.key --contract $POOL --method add_liquidity \
  --args $(enc_u128 10000000000000)$(enc_u128 5000000000) --fund 5000000000 \
  --fee-token 1 --wait | tee add.json | brief
dec_u128 $(jq -r .status.receipt.output add.json)
Output
{
  "nonce": "3",
  "op_id": "ff4586be24579b8929a77f5c0c83b5464bf29759a36018e72702462ff2a3de6d",
  "status": "fast_confirmed",
  "block": "268",
  "gas_used": "182212",
  "error": null,
  "output": "b5f50010340000000000000000000000"
}
223606797749

The output is your number of pool shares. The first deposit receives the integer square root of the product of the two amounts. A later deposit receives shares in proportion to the reserves; the pool takes only what the current ratio needs and refunds the rest of an attached asset.

Quote a swap

swap takes (id_in: [u8; 32], amount_in: u128, min_out: u128) and keeps a fee of 0.3% for the pool. To see what a swap returns, run it as a query with the same funds attached, and with your account as the caller so that the simulated transfer has KSA to move:

Shell
QUOTE=$(ksana-cli query --contract $POOL --method swap \
  --args $KSA$(enc_u128 100000000)$(enc_u128 1) --fund 100000000 --caller $ACCOUNT \
  | jq -r .output)
dec_u128 $QUOTE
MIN_OUT=$(( $(dec_u128 $QUOTE) * 99 / 100 ))
echo $MIN_OUT
Output
195501696178
193546679216

At these reserves, 1 KSA buys about 1,955.02 TUT. MIN_OUT accepts up to 1% less, in case another trade moves the price before yours.

Swap KSA for TUT

Send the same swap as an op, with MIN_OUT as the limit:

Shell
ksana-cli call --key alice.key --contract $POOL --method swap \
  --args $KSA$(enc_u128 100000000)$(enc_u128 $MIN_OUT) --fund 100000000 \
  --fee-token 1 --wait | tee swap1.json | brief
Output
{
  "nonce": "4",
  "op_id": "95536d3e8c77f64e16126ae83c5aa628a393a269d9e4f6210e6f4815018bfe82",
  "status": "fast_confirmed",
  "block": "269",
  "gas_used": "144458",
  "error": null,
  "output": "b224cf842d0000000000000000000000"
}

The output is the TUT you received, 195,501,696,178 base units: nothing traded in between, so the quote was exact. Had the price moved past MIN_OUT, the op would have failed: a failed op keeps its fee and its nonce, and changes nothing else.

Swap TUT for KSA

Selling TUT works the same way, except that the pool pulls the TUT through your allowance instead of attached funds. The deposit used up the first allowance, so approve 1,000 TUT, then quote:

Shell
ksana-cli call --key alice.key --contract $TOKEN --method approve \
  --args $POOL$(enc_u128 100000000000) --fee-token 1 --wait | tee approve2.json | brief
QUOTE=$(ksana-cli query --contract $POOL --method swap \
  --args $TOKEN$(enc_u128 100000000000)$(enc_u128 1) --caller $ACCOUNT | jq -r .output)
dec_u128 $QUOTE
MIN_OUT=$(( $(dec_u128 $QUOTE) * 99 / 100 ))
echo $MIN_OUT
Output
{
  "nonce": "5",
  "op_id": "9ce4f9f63517986d438a0f0623f1b8ac4ee215ea968508e8527ac96c4ce2ede1",
  "status": "fast_confirmed",
  "block": "270",
  "gas_used": "48026",
  "error": null,
  "output": ""
}
51338834
50825445

Then swap:

Shell
ksana-cli call --key alice.key --contract $POOL --method swap \
  --args $TOKEN$(enc_u128 100000000000)$(enc_u128 $MIN_OUT) \
  --fee-token 1 --wait | tee swap2.json | brief
Output
{
  "nonce": "6",
  "op_id": "e326283aa0609470c215ad2f79c9867d0815012f32c616e69c88bcd5e6df1413",
  "status": "fast_confirmed",
  "block": "271",
  "gas_used": "152265",
  "error": null,
  "output": "525e0f03000000000000000000000000"
}

1,000 TUT bought 51,338,834 base units of KSA, about 0.513 KSA.

Remove liquidity

remove_liquidity takes a number of shares and pays out that fraction of both reserves, rounded down. Read your shares and remove half of them:

Shell
dec_u128 $(ksana-cli query --contract $POOL --method shares_of --args $ACCOUNT | jq -r .output)
ksana-cli call --key alice.key --contract $POOL --method remove_liquidity \
  --args $(enc_u128 111803398874) --fee-token 1 --wait | tee remove.json | brief
dec_u128 $(jq -r .status.receipt.output remove.json)
Output
223606797749
{
  "nonce": "7",
  "op_id": "1d9adba6e303c417f287022e7c5f1fb9c3cd48b92794f55d780e7c91aa01e70b",
  "status": "fast_confirmed",
  "block": "272",
  "gas_used": "170509",
  "error": null,
  "output": "90310d09810400000000000000000000563a7696000000000000000000000000"
}
4952249151888 2524330582

The output is (u128, u128): 49,522.49151888 TUT and 25.24330582 KSA came back to your account.

Create a pool with the factory

amm-factory creates pools with create_contract, which instantiates code already stored on chain: the pool you deployed above stored the amm code. The factory’s init takes the hash of that code:

Shell
ksana-cli deploy --key alice.key --code $W/amm_factory.wasm \
  --init-args $(code_hash $W/amm.wasm) --fee-token 1 --wait | tee factory.json | brief
FACTORY=$(contract_id $ACCOUNT $(jq -r .nonce factory.json) $W/amm_factory.wasm)
echo $FACTORY
Output
{
  "nonce": "8",
  "op_id": "7e3c309278bbad5cc0f0c0dfcb55bbd3689cdced9d4410c704e2f09c89330026",
  "status": "fast_confirmed",
  "block": "273",
  "gas_used": "326178",
  "error": null,
  "output": ""
}
5ba8f13c2f75c64be4852d39eea42ec84840c811d1f2199f71a7e2a7c407e4bd

create_pair takes two sides, creates the pool, and returns its ID. get_pair finds it again, with the sides in either order, and returns a Borsh Option: 01 and the ID, or 00 when there is no such pool:

Shell
ksana-cli call --key alice.key --contract $FACTORY --method create_pair \
  --args 00${TOKEN}01${KSA} --fee-token 1 --wait | tee pair.json | brief
PAIR=$(jq -r .status.receipt.output pair.json)
ksana-cli query --contract $FACTORY --method get_pair --args 01${KSA}00${TOKEN} | jq -r .output
Output
{
  "nonce": "9",
  "op_id": "2f9117f181b5ea9b3269d91503bb564e77bd4eae54d8a6f48f4d6c77f1e1cc3d",
  "status": "fast_confirmed",
  "block": "274",
  "gas_used": "238389",
  "error": null,
  "output": "94e081b6f8de861947733beb835c70ba16049c89926be3f81e5f8587f15bac90"
}
0194e081b6f8de861947733beb835c70ba16049c89926be3f81e5f8587f15bac90

The factory sorts the two sides into one order, so each pair of sides gets one pool. KSA’s ID is all zeros, so in this pool side A is KSA and side B is TUT: give add_liquidity the KSA amount first. The new pool is empty and separate from the one you deployed yourself; fund it with approve and add_liquidity as before.

Follow an op to final

Your ops are fast-confirmed. Each one is on Bitcoin once a settlement carrying its block confirms on Bitcoin, and final once that settlement has 6 confirmations. Ask for the deploy op and for the settlement of its block, 264:

Shell
ksana-cli op $(jq -r .op_id token.json) | jq '{status, block: .block.height}'
ksana-cli settlement 264 | jq '{status, txid_rpc, confirmed, settled, btc_final}'
Output
{
  "status": "btc_final",
  "block": "264"
}
{
  "status": "confirmed",
  "txid_rpc": "e84abcb90855f3b4bf43989d21814c5d5bd70592d88d25e18f5c1c7cb0e8caa7",
  "confirmed": {
    "block": "c48a4f4f1f7ce1e696becf01bbc5f15fdbf8ba7145b9badff31c7e7711000000",
    "confirmations": 6,
    "height": 326322,
    "txid": "a7cae8b07c1c5c8fe1258dd89205d75b5d4c81219d9843bfb4f35508b9bc4ae8"
  },
  "settled": true,
  "btc_final": true
}

The op became btc_final about an hour after its block, once the Bitcoin transaction carrying the block’s settlement had 6 confirmations. txid_rpc is that transaction’s ID as explorers show it, while confirmed.txid and confirmed.block are in Bitcoin’s internal byte order, so the block hash ends in zeros. Your other ops follow as the settlements of their blocks reach 6 confirmations. A failed op never shows btc_final: it stays failed even once its block is final.

How arguments are encoded

A method’s arguments are a Borsh tuple: the encodings of its parameters, back to back. A method with one parameter takes that parameter’s encoding, and a method with none takes empty bytes. Return values follow the same rules.

Type Borsh bytes Helper or example
u8 1 byte 08
u128 16 bytes, little-endian enc_u128 5000000000, dec_u128
String u32 length, little-endian, then UTF-8 enc_string TUT gives 03000000545554
[u8; 32] the 32 bytes, with no length an account, a contract ID, $KSA
Option<T> 00, or 01 then T the output of get_pair
Side of amm 00 (token) or 01 (asset), then the ID 00${TOKEN}, 01${KSA}

Write a contract explains the conventions from the contract’s side.

If something goes wrong

  • The status is failed. error says why, for example revert: insufficient allowance when the pool’s allowance is too small; approve more and try again. A failed op still uses its nonce and pays its fee.
  • --wait times out. After 120 seconds (--timeout) the CLI prints only an error, without the op ID, although the op was sent. Run ksana-cli account @alice.key: when nonce has gone up, the op is in a block, and ksana_getAccountOps in the JSON-RPC API lists it.
  • The node refuses the op. It refuses a fee below its floor. The CLI takes the nonce from the account’s state, so a second op sent before the first is in a block gets the same nonce: the node refuses it, or, when its fee is higher, drops the first op for it. Send ops one at a time with --wait, as on this page.

Next steps