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
- Before you start
- Build the contracts
- Load the helpers
- Make a key and get KSA
- Deploy the token
- Find the token’s ID
- Deploy a pool
- Approve the pool
- Add liquidity
- Quote a swap
- Swap KSA for TUT
- Swap TUT for KSA
- Remove liquidity
- Create a pool with the factory
- Follow an op to final
- How arguments are encoded
- If something goes wrong
- Next steps
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-clion yourPATH, built from the repository as in the quickstart (ksana-cli describes every command);- the repository’s Rust toolchain with the
wasm32-unknown-unknowntarget:rustup toolchain install, run inside the clone, installs both fromrust-toolchain.toml; bash,python3andjq, 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:
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.wasm11572 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 totalThe 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:
# 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:
source ./helpers.sh
export KSANA_RPC=https://signet.ksana.network/rpc
KSA=0000000000000000000000000000000000000000000000000000000000000000How 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:
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{
"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:
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 | brief0e0000005475746f7269616c20546f6b656e030000005455540800407a10f35a00000000000000000000
{
"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:
TOKEN=$(contract_id $ACCOUNT $(jq -r .nonce token.json) $W/token.wasm)
echo $TOKEN
jq -r '.status.receipt.events[0].contract' token.jsonf622bdcea40708c8776b36dbe387e7285b57f8c3734d370a31b63c5c10756740
f622bdcea40708c8776b36dbe387e7285b57f8c3734d370a31b63c5c10756740The 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:
ksana-cli query --contract $TOKEN --method metadata
ksana-cli query --contract $TOKEN --method balance_of --args $ACCOUNT | jq -r .output{
"error": null,
"events": [],
"gas_used": "37780",
"op_id": null,
"output": "0e0000005475746f7269616c20546f6b656e0300000054555408",
"success": true
}
00407a10f35a00000000000000000000The 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:
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{
"nonce": "1",
"op_id": "fe894004a8b65f507112a395ef01b9648c399b0471f9af98b621d85d5d625ce2",
"status": "fast_confirmed",
"block": "266",
"gas_used": "574872",
"error": null,
"output": ""
}
2d6e5edc46352b2931657479fce65fdbf01aafc5acc3de7c27660ce19dda8171The 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:
ksana-cli call --key alice.key --contract $TOKEN --method approve \
--args $POOL$(enc_u128 10000000000000) --fee-token 1 --wait | tee approve.json | brief{
"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:
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){
"nonce": "3",
"op_id": "ff4586be24579b8929a77f5c0c83b5464bf29759a36018e72702462ff2a3de6d",
"status": "fast_confirmed",
"block": "268",
"gas_used": "182212",
"error": null,
"output": "b5f50010340000000000000000000000"
}
223606797749The 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:
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_OUT195501696178
193546679216At 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:
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{
"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:
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{
"nonce": "5",
"op_id": "9ce4f9f63517986d438a0f0623f1b8ac4ee215ea968508e8527ac96c4ce2ede1",
"status": "fast_confirmed",
"block": "270",
"gas_used": "48026",
"error": null,
"output": ""
}
51338834
50825445Then swap:
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{
"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:
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)223606797749
{
"nonce": "7",
"op_id": "1d9adba6e303c417f287022e7c5f1fb9c3cd48b92794f55d780e7c91aa01e70b",
"status": "fast_confirmed",
"block": "272",
"gas_used": "170509",
"error": null,
"output": "90310d09810400000000000000000000563a7696000000000000000000000000"
}
4952249151888 2524330582The 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:
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{
"nonce": "8",
"op_id": "7e3c309278bbad5cc0f0c0dfcb55bbd3689cdced9d4410c704e2f09c89330026",
"status": "fast_confirmed",
"block": "273",
"gas_used": "326178",
"error": null,
"output": ""
}
5ba8f13c2f75c64be4852d39eea42ec84840c811d1f2199f71a7e2a7c407e4bdcreate_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:
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{
"nonce": "9",
"op_id": "2f9117f181b5ea9b3269d91503bb564e77bd4eae54d8a6f48f4d6c77f1e1cc3d",
"status": "fast_confirmed",
"block": "274",
"gas_used": "238389",
"error": null,
"output": "94e081b6f8de861947733beb835c70ba16049c89926be3f81e5f8587f15bac90"
}
0194e081b6f8de861947733beb835c70ba16049c89926be3f81e5f8587f15bac90The 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:
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}'{
"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.errorsays why, for examplerevert: insufficient allowancewhen the pool’s allowance is too small; approve more and try again. A failed op still uses its nonce and pays its fee. --waittimes out. After 120 seconds (--timeout) the CLI prints only an error, without the op ID, although the op was sent. Runksana-cli account @alice.key: whennoncehas gone up, the op is in a block, andksana_getAccountOpsin 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
- Example contracts Every method of the token, the pool and the factory, and the other examples.
- Write a contract Start your own contract, test it, and deploy it.
- TypeScript SDK Call the same contracts from a web app or a script.