
Contracts that remember now run on Kaspa. Since the Toccata upgrade, a coin can hold a contract's state and refuse any spend that breaks its rules, and this guide shows you how to build one. It starts with a plain payment and ends with a counter that refused every cheat kaspa.news threw at it. Every command and every line of code in between ran on a real Kaspa node before it went in, and where something failed the first time, the guide tells you why.
The guide is for anyone who wants to build on Kaspa, whether you've never written a contract or you've shipped apps on Ethereum or Solana. You'll need to run commands in a terminal and read a little Python. If you've never coded, read the ideas and stop after the first payment, which already shows how Kaspa works.
It follows one line of thought instead of trying to cover everything. Michael Sutton, who designed much of the covenant system, explained the plan for apps on Kaspa as a ladder with four rungs, and the guide climbs it in the same order.
- A script decides who may spend a coin.
- A covenant also decides what the next coin must look like.
- A covenant ID proves which coin is the real one.
- Zero-knowledge proofs check work that was done off-chain.
kaspa.news ran the first three with Kaspa node 2.1.0, the current release, first on a private network and then on the public test network, and tried to cheat each one. The fourth rung is real but early, and the guide shows where it stands.
Where things stand
- Payments work on mainnet, and the SDKs for Rust, JavaScript and Python are current.
- Contracts with rules have worked on mainnet since the Toccata upgrade of 30 June 2026. SilverScript, the language for writing them, has a 1.0 release.
- Contracts that keep state, called covenants, are part of the consensus rules on mainnet.
- Apps made of several contracts are written in Argent, which has no release yet and changes every week.
- Tokens follow KCC-20, a shared convention that is in its final review.
- None of the contract tools has a published independent audit yet. The audit section near the end says what has been checked, and by whom.
Before the first rung. Coins, not accounts
On Ethereum, your ETH is a number in an account, and a contract is an account with its own storage that everyone calls. Solana also uses accounts, but keeps a program's code and its data in separate accounts. Kaspa has no accounts at all. It has coins.
A Kaspa coin is an amount of KAS with a lock on it. The technical name is an unspent transaction output, or UTXO. A wallet holds many separate coins, and its balance is just their total. To pay someone, a transaction opens some of your coins and creates new ones, one for the recipient and usually one for your change. The old coins are gone. The network also makes sure a transaction never creates more KAS than it spends, which is the one rule every coin obeys.
The lock is a short program called a script. Sutton calls it a constitution that governs a resource. Most coins have the simplest constitution there is, "a valid signature from this key may spend me". Building on Kaspa means writing more interesting constitutions.
A few words come up again and again.
- Sompi. One KAS is 100,000,000 sompi, and every program counts in sompi, the way Ethereum counts in wei and Solana in lamports.
- Address. Made from a key and safe to share. It starts with
kaspa:on mainnet,kaspatest:on the public test network andkaspadev:on a private one. - Node. A program that keeps the chain, checks every block and answers other programs. The Kaspa node is
kaspad. - Mempool. A node's waiting room for transactions it has accepted but no block has included yet.
- DAA score. A counter that goes up with every block, about ten times a second on mainnet. Kaspa uses it as its clock.
- Mass. How heavy a transaction is for the network, counted in grams. Fees are charged per gram.
Set up, step by step
Everything here happens in a terminal, the window where you type commands. On Linux and macOS it's called Terminal, and on Windows it's PowerShell. The commands below are for Linux, where kaspa.news ran every one of them, starting from an empty folder. The same tools exist for macOS and Windows under the file names given in step 1, but kaspa.news didn't test them there.
By the end you'll have three terminal windows open, because two programs keep running while you work. The node window runs your node, the miner window runs the miner for a few minutes, and the scripts window is where you run your own programs.
Step 1. Make a folder and download the tools
Open a terminal. This one becomes your node window. Type these lines one at a time.
1mkdir kaspa-lab
2cd kaspa-lab
3curl -LO https://github.com/kaspanet/rusty-kaspa/releases/download/v2.1.0/rusty-kaspa-v2.1.0-linux-amd64.zip
4curl -LO https://github.com/kaspanet/cpuminer/releases/download/v0.2.7/kaspa-miner-v0.2.7-linux-gnu-amd64
5curl -LO https://github.com/kaspanet/silverscript/releases/download/v1.0.0/silverc-linux-x86_64.tar.gz
6unzip rusty-kaspa-v2.1.0-linux-amd64.zip
7tar -xzf silverc-linux-x86_64.tar.gz
8mv kaspa-miner-v0.2.7-linux-gnu-amd64 kaspa-miner
9chmod +x kaspa-minerThe folder kaspa-lab now holds three programs. bin/kaspad is the node, kaspa-miner makes new blocks and silverc is the SilverScript compiler. On a Mac, download the files with osx and darwin in their names from the same release pages (node, miner, compiler), and on Windows the ones with win64 and windows.
Step 2. Install the Python SDK
Stay in the node window, inside kaspa-lab, and run these three lines.
1python3 -m venv .venv
2source .venv/bin/activate
3pip install kaspa==2.1.0The first line makes a private Python setup inside the folder, so the Kaspa SDK doesn't mix with anything else on your computer. The second switches it on. The SDK needs Python 3.10 to 3.14, and python3 --version tells you which one you have.
Step 3. Let the terminal find the tools
1export PATH="$PWD/bin:$PWD:$PATH"Now you can type kaspad, kaspa-miner and silverc without saying where they are. This lasts only until the window closes. So every time you open a new window, run these three lines first. They go into the folder, switch the Python setup back on and repeat the line above.
1cd kaspa-lab
2source .venv/bin/activate
3export PATH="$PWD/bin:$PWD:$PATH"Step 4. Start your own network in the node window
Start with a private network of your own, called a devnet. Coins appear in minutes, nothing costs anything, and every Toccata feature is on from the first block.
1kaspad --devnet --utxoindex --enable-unsynced-mining \
2 --appdir=./devnet-data --nologfiles \
3 --listen=127.0.0.1:16611 --rpclisten=127.0.0.1:16610 \
4 --rpclisten-borsh=127.0.0.1:17610 --rpclisten-json=127.0.0.1:18610 \
5 --disable-upnp --nodnsseed --yesThe node keeps printing lines about what it's doing. That's normal, so leave this window open. --utxoindex lets programs ask for the coins of an address, --enable-unsynced-mining allows mining on a network that has never been synced, which a new devnet never is, and the 127.0.0.1 addresses keep everything on your machine. Ctrl+C stops the node, and deleting the devnet-data folder starts your network again from nothing.
Step 5. Make your key and find your address in the scripts window
Open a second terminal. This is your scripts window. Run the three lines from step 3, then make a new file called make_key.py inside kaspa-lab in any text editor, paste this code into it and save it.
1import os, secrets
2from kaspa import PrivateKey
3
4if os.path.exists("devnet-key.hex"):
5 key_hex = open("devnet-key.hex").read().strip()
6 print("You already have a key. Your address is")
7else:
8 key_hex = secrets.token_hex(32)
9 with open("devnet-key.hex", "w") as f:
10 f.write(key_hex)
11 os.chmod("devnet-key.hex", 0o600)
12 print("New key saved in devnet-key.hex. Your address is")
13print(PrivateKey(key_hex).to_address("devnet").to_string())Run it.
1python make_key.py1New key saved in devnet-key.hex. Your address is
2kaspadev:qrrthnx3uyhug0rhfjzg03xujnz9wfkgns0m4jpu46hld5m3xarlztr65j5ytThe long line starting with kaspadev: is your address, and yours will be different. Copy the whole line, kaspadev: included. You can share an address with anyone. The key in devnet-key.hex is the secret that controls the coins, so never share that file. If you forget your address, run python make_key.py again. Once a key exists, it won't make a new one, so it simply prints the address of the key you have. A new key would mean starting over with no coins.
Step 6. Mine some coins in the miner window
Open a third terminal, the miner window, run the three lines from step 3 and start the miner. Put your own address from step 5 where it says kaspadev:YOUR_ADDRESS.
1kaspa-miner -a kaspadev:YOUR_ADDRESS -s 127.0.0.1 -p 16610 -t 2 --mine-when-not-synced-a is the address that receives the new coins, -s and -p point the miner at your node, and -t 2 uses two processor threads. Every block it finds prints a line starting Found a block, and each block pays your address. On the test PC, two threads found about five blocks a second.
Step 7. Check your balance in the scripts window
Save this as balance.py and run it with python balance.py.
1import asyncio
2from kaspa import PrivateKey, RpcClient
3
4key = PrivateKey(open("devnet-key.hex").read().strip())
5address = key.to_address("devnet").to_string()
6
7async def main():
8 rpc = RpcClient(url="ws://127.0.0.1:17610", network_id="devnet")
9 await rpc.connect()
10 daa = (await rpc.get_block_dag_info())["virtualDaaScore"]
11 coins = (await rpc.get_utxos_by_addresses({"addresses": [address]}))["entries"]
12 # Newly mined coins can be spent once the DAA score has moved 1,000 past them.
13 ready = [c for c in coins
14 if not c["utxoEntry"]["isCoinbase"] or c["utxoEntry"]["blockDaaScore"] + 1000 < daa]
15 total = sum(c["utxoEntry"]["amount"] for c in coins) / 100_000_000
16 spendable = sum(c["utxoEntry"]["amount"] for c in ready) / 100_000_000
17 print(address)
18 print(len(coins), "coins worth", total, "KAS, of which", spendable, "KAS can be spent now")
19 await rpc.disconnect()
20
21asyncio.run(main())1kaspadev:qrrthnx3uyhug0rhfjzg03xujnz9wfkgns0m4jpu46hld5m3xarlztr65j5yt
21007 coins worth 44308.0 KAS, of which 264.0 KAS can be spent nowThe node hands back your coins, not a balance, and the script adds them up. A newly mined coin has to wait until the DAA score, the block counter, has moved 1,000 past it, and the score moves only while blocks are being made. So keep the miner running until the last number is above zero, which took about three to three and a half minutes on the test PC. Then give it one more minute so you have enough coins for every example, and stop it with Ctrl+C in the miner window. Leave the node running. On the test PC, one long session with the node and the miner running ended with a frozen computer, so mine only when you need to.
One thing surprises everyone at this point. Your node checks every transaction the moment it arrives and tells you straight away whether it accepts it, which is all the examples below need. But an accepted transaction waits in the node's waiting room, the mempool, until a block includes it, and with the miner stopped, no blocks are made. So your balance won't change after the examples run. To see them land, start the miner again for half a minute. On the test PC, 30 seconds of mining took every waiting transaction into a block, and the 25 KAS from the payment below appeared at the friend's address. On mainnet and testnet, other people's miners do this for you within seconds.
From here on, every script goes into the kaspa-lab folder and runs in the scripts window with python and its file name.
Rung 1. A script decides who may spend
A payment
Save this as send.py and run python send.py. It sends 25 KAS to a brand-new address, the way you'd pay a friend.
1import asyncio, secrets
2from kaspa import PrivateKey, RpcClient, create_transactions
3
4key = PrivateKey(open("devnet-key.hex").read().strip())
5me = key.to_address("devnet")
6friend = PrivateKey(secrets.token_hex(32)).to_address("devnet")
7
8async def main():
9 rpc = RpcClient(url="ws://127.0.0.1:17610", network_id="devnet")
10 await rpc.connect()
11 daa = (await rpc.get_block_dag_info())["virtualDaaScore"]
12 coins = (await rpc.get_utxos_by_addresses({"addresses": [me.to_string()]}))["entries"]
13 # Mined coins can be spent once they are 1,000 DAA steps old.
14 ready = [c for c in coins
15 if not c["utxoEntry"]["isCoinbase"] or c["utxoEntry"]["blockDaaScore"] + 1000 < daa]
16 result = create_transactions(network_id="devnet", entries=ready[:50], change_address=me,
17 outputs=[{"address": friend, "amount": 25 * 100_000_000}],
18 priority_fee=0)
19 for tx in result["transactions"]:
20 tx.sign([key])
21 print("sent", await tx.submit(rpc), "fee", tx.fee_amount, "sompi to", friend.to_string())
22 await rpc.disconnect()
23
24asyncio.run(main())1sent 6e84aaa35f60bb0638b6e09da3b117bb3a52657f1250cb34af007555ebba95c8 fee 203600 sompi to kaspadev:qq59vpfn7rmhauwf4hcylxpmpltsjd20lk93lescqav7m8epjqnhuxudstxyacreate_transactions does what a wallet does. It picks coins, adds a change coin and works out the fee, which was 203,600 sompi, about 0.002 KAS. If you come from Ethereum, notice what's missing. There's no nonce to track and no gas limit to guess.
A lock with rules
Now write a constitution of your own. This one releases coins only to the owner's key, and only together with a secret word whose fingerprint was fixed in advance. Save it as hashkeylock.sil in kaspa-lab.
1pragma silverscript ^0.1.0;
2
3contract HashKeyLock(pubkey owner, byte[32] secretHash) {
4 entry claim(sig ownerSig, byte[] secret) {
5 require(blake2b(secret) == secretHash);
6 require(checkSig(ownerSig, owner));
7 }
8}The two values in the contract line are baked in when you compile, so every owner and secret gives a different script and a different address. claim is the only way to open the coin, and both require lines must pass. The signature matters even though there's a secret. The moment you spend, the secret sits in the transaction for anyone to read, and the signature stops someone from copying it into a spend of their own. Write the pragma as ^0.1.0 even though the release is 1.0.0. The compiler still calls itself 0.1.0 inside, and ^1.0.0 fails with "cannot support pragmas that cover future major versions".
There's no deploy step. Compiling gives you an address, and sending coins to it is what puts the contract to work. This script compiles the contract, locks 10 KAS in it, then tries a wrong secret and the right one. Save it as lock.py and run python lock.py. It calls silverc itself, which is why step 3 matters.
1import asyncio, hashlib, json, subprocess
2from kaspa import (PrivateKey, RpcClient, ScriptBuilder, SighashType, Transaction, TransactionInput,
3 TransactionOutput, TransactionOutpoint, UtxoEntryReference, address_from_script_public_key,
4 create_input_signature, create_transactions, pay_to_address_script,
5 pay_to_script_hash_script, pay_to_script_hash_signature_script)
6
7NET = "devnet"
8key = PrivateKey(open("devnet-key.hex").read().strip())
9me = key.to_address(NET)
10secret = b"open sesame"
11
12# 1. Compile the contract with the owner's public key and the hash of the secret baked in.
13owner = bytes.fromhex(key.to_public_key().to_x_only_public_key().to_string())
14secret_hash = hashlib.blake2b(secret, digest_size=32).digest()
15json.dump([{"kind": "bytes", "value": list(owner)}, {"kind": "bytes", "value": list(secret_hash)}],
16 open("ctor.json", "w"))
17subprocess.run(["silverc", "--constructor-args", "ctor.json", "-o", "lock.json", "hashkeylock.sil"], check=True)
18contract = json.load(open("lock.json"))["contracts"]["HashKeyLock"]
19script = bytes(contract["compiled"]["bytecode"]).hex()
20tag = contract["entries"]["claim"]["dispatch_tag"]
21lock_address = address_from_script_public_key(pay_to_script_hash_script(script), NET)
22print("contract address", lock_address.to_string())
23
24
25async def main():
26 rpc = RpcClient(url="ws://127.0.0.1:17610", network_id=NET)
27 await rpc.connect()
28 daa = (await rpc.get_block_dag_info())["virtualDaaScore"]
29 coins = (await rpc.get_utxos_by_addresses({"addresses": [me.to_string()]}))["entries"]
30 ready = [c for c in coins
31 if not c["utxoEntry"]["isCoinbase"] or c["utxoEntry"]["blockDaaScore"] + 1000 < daa]
32
33 # 2. Lock 10 KAS in the contract. The payment is output 0, the change comes after it.
34 amount = 10 * 100_000_000
35 funding = create_transactions(network_id=NET, entries=ready[:50], change_address=me,
36 outputs=[{"address": lock_address, "amount": amount}],
37 priority_fee=0)["transactions"][-1]
38 funding.sign([key])
39 funding_id = await funding.submit(rpc)
40 print("locked 10 KAS in", funding_id)
41
42 # 3. Build the transaction that takes the 10 KAS back out, minus a 0.002 KAS fee.
43 locked_coin = UtxoEntryReference.from_dict({
44 "address": lock_address.to_string(),
45 "outpoint": {"transactionId": funding_id, "index": 0},
46 "utxoEntry": {"amount": amount, "scriptPublicKey": "0000" + pay_to_script_hash_script(script).script,
47 "blockDaaScore": daa, "isCoinbase": False, "covenantId": None}})
48
49 def claim(unlock=""):
50 spend = TransactionInput(TransactionOutpoint(funding_id, 0), unlock, 0, 1, utxo=locked_coin)
51 pay_me = TransactionOutput(amount - 200_000, pay_to_address_script(me))
52 return Transaction(0, [spend], [pay_me], 0, "00" * 20, 0, "", 0)
53
54 def unlocking_script(guess):
55 # The SDK returns the signature with a one-byte length marker in front. Drop it.
56 signature = create_input_signature(claim(), 0, key, SighashType.All)[2:]
57 args = ScriptBuilder()
58 args.add_data(signature)
59 args.add_data(guess.hex())
60 args.add_data(tag)
61 return pay_to_script_hash_signature_script(script, args.to_string())
62
63 # 4. A wrong secret must fail. The right one must work.
64 for guess in (b"wrong guess", secret):
65 try:
66 result = await rpc.submit_transaction({"transaction": claim(unlocking_script(guess)),
67 "allowOrphan": False})
68 print(guess, "accepted", result)
69 except Exception as error:
70 print(guess, "rejected", str(error)[-90:])
71 await rpc.disconnect()
72
73asyncio.run(main())1contract address kaspadev:prhrgrm3pmnkgj6hz02ycgwkkr32a7agggxth0h9z0ck0l6l924zj00vnw3jl
2locked 10 KAS in 3e0dc8ca4e28dbd05841e0a9cc97a9c061d5c2f7c9701f478fbc6d535bc04cf5
3b'wrong guess' rejected ...: failed to verify the signature script: script ran, but verification failed
4b'open sesame' accepted {'transactionId': '958919d2...'}The address starts with kaspadev:p, and the p means pay-to-script-hash. The address is only a fingerprint of the script, and whoever spends the coin must show the full script and satisfy it. That's what unlocking_script builds. First come the arguments of claim in the order they're declared, the signature and then the secret. Next comes the four-byte dispatch tag, which names the entry point. The full script goes last.
The SDK's wallet functions only know how to open ordinary coins, so the claim is built by hand, and two details cost a failed run each. create_input_signature returns the signature with a one-byte length marker already in front, and until the code dropped it with [2:], every claim failed, the right secret included. And UtxoEntryReference.from_dict in SDK 2.1.0 refuses a coin without a covenantId field, so ordinary coins need "covenantId": None. The node never says which require failed. "Script ran, but verification failed" is all you get, and the SilverScript debugger in the toolbox section is how you find out.
This rung has one limit, and it's the reason for the next one. A script can decide whether a coin may be opened, but it can't see where the money goes afterwards.
Rung 2. A covenant decides what comes next
Toccata let scripts read the transaction that spends them. A lock can now say "you may open me only if you also create a new coin with my rules and this new value inside". That's a covenant, and it turns a coin into a state machine. The state lives inside the coin's script, and every update spends the old coin and creates its successor.
The simplest example is a counter that holds one number and lets anyone add between 1 and 10 to it. Save it as counter.sil in kaspa-lab.
1pragma silverscript ^0.1.0;
2
3contract Counter(int max_step, int init_count) {
4 int count = init_count;
5
6 #[covenant.singleton(mode = transition)]
7 function increment(State prev_state, int step) : (State) {
8 require(step > 0);
9 require(step <= max_step);
10 return(State { count: prev_state.count + step });
11 }
12}You write only the business rule, that a step is between 1 and max_step. The line #[covenant.singleton(mode = transition)] tells the compiler that one counter coin is always spent into exactly one new counter coin, and the compiler writes every check that ties the new coin to the same code with the right new number. Sutton calls this security by construction. Approachability "isn't just syntax", he wrote. It means making it "easy to write covenants that correctly validate state transitions, and hard to accidentally ship an insecure scheme". Write the return with the word State, as above. The older Counter example on docs.kaspa.org writes return({ ... }), which the 1.0.0 compiler rejects.
The compiler's output file says where the state sits inside the script. For the counter, state_span is 9 bytes at offset 1, a push byte followed by the count as an 8-byte number. So you compile once and write each new count into that slot. kaspa.news checked the slot against fresh compilations for five different counts, and the scripts matched byte for byte.
Here's the whole program. It creates the counter, tries to cheat it three ways and then makes two honest updates. Save it as counter_demo.py and run python counter_demo.py.
1import asyncio, json, subprocess
2from kaspa import (PrivateKey, RpcClient, ScriptBuilder, SighashType, Transaction, TransactionInput,
3 TransactionOutput, TransactionOutpoint, UtxoEntryReference, CovenantBinding,
4 GenesisCovenantGroup, Hash, address_from_script_public_key, create_input_signature,
5 pay_to_address_script, pay_to_script_hash_script, pay_to_script_hash_signature_script)
6
7NET, NET_ID, URL, KEY = "devnet", "devnet", "ws://127.0.0.1:17610", "devnet-key.hex"
8key = PrivateKey(open(KEY).read().strip())
9me = key.to_address(NET).to_string()
10my_lock = pay_to_address_script(key.to_address(NET))
11NATIVE = "00" * 20 # ordinary transactions use the all-zero subnetwork
12
13# Compile once. The compiler reports where the state sits in the script (state_span).
14json.dump([{"kind": "int", "value": 10}, {"kind": "int", "value": 0}], open("ctor.json", "w"))
15subprocess.run(["silverc", "--constructor-args", "ctor.json", "-o", "counter.json", "counter.sil"], check=True)
16counter = json.load(open("counter.json"))["contracts"]["Counter"]
17(entry_name, entry), = counter["entries"].items()
18tag = entry["dispatch_tag"]
19span = counter["compiled"]["state_span"]
20
21
22def script_with(count):
23 """The counter's script holding `count`. The state is an 8-byte number after a push byte."""
24 s = bytearray(counter["compiled"]["bytecode"])
25 s[span["offset"]:span["offset"] + span["len"]] = bytes([8]) + count.to_bytes(8, "little", signed=True)
26 return bytes(s).hex()
27
28
29def lock_with(count):
30 return pay_to_script_hash_script(script_with(count))
31
32
33def coin(txid, index, amount, lock, daa, covenant_id=None):
34 return {"outpoint": {"transactionId": txid, "index": index},
35 "utxoEntry": {"amount": amount, "scriptPublicKey": "0000" + lock.script,
36 "blockDaaScore": daa, "isCoinbase": False, "covenantId": covenant_id}}
37
38
39def spend(c, address, unlock="", budget=0):
40 ref = UtxoEntryReference.from_dict({"address": address, **c})
41 op = c["outpoint"]
42 return TransactionInput(TransactionOutpoint(op["transactionId"], op["index"]), unlock, 0, 0,
43 compute_budget=budget, utxo=ref)
44
45
46def signed(build, index):
47 """Build the transaction, sign the wallet input at `index`, build it again with the signature."""
48 signature = create_input_signature(build(""), index, key, SighashType.All)[2:]
49 args = ScriptBuilder()
50 args.add_data(signature)
51 return build(args.to_string())
52
53
54def counter_unlock(count, step):
55 args = ScriptBuilder()
56 args.add_i64(step)
57 args.add_data(tag)
58 return pay_to_script_hash_signature_script(script_with(count), args.to_string())
59
60
61async def main():
62 rpc = RpcClient(url=URL, network_id=NET_ID)
63 await rpc.connect()
64 daa = (await rpc.get_block_dag_info())["virtualDaaScore"]
65 mine = (await rpc.get_utxos_by_addresses({"addresses": [me]}))["entries"]
66 ready = [c for c in mine
67 if not c["utxoEntry"]["isCoinbase"] or c["utxoEntry"]["blockDaaScore"] + 1000 < daa]
68 # The biggest coin pays for the counter's 5 KAS, the next three pay one fee each.
69 wallet = sorted(ready, key=lambda c: c["utxoEntry"]["amount"], reverse=True)[:4]
70 fee = 1_000_000 # 0.01 KAS, more than enough at the minimum fee rate
71
72 # 1. Genesis. A wallet coin goes in. The counter coin (5 KAS, count 0) and change come out.
73 w = wallet[0]
74 def genesis(unlock):
75 tx = Transaction(1, [spend(w, me, unlock, budget=11)],
76 [TransactionOutput(5 * 100_000_000, lock_with(0)),
77 TransactionOutput(w["utxoEntry"]["amount"] - 5 * 100_000_000 - fee, my_lock)],
78 0, NATIVE, 0, "", 0)
79 tx.populate_genesis_covenants([GenesisCovenantGroup(0, [0])])
80 return tx
81 g = signed(genesis, 0)
82 covenant_id = g.to_dict()["outputs"][0]["covenant"]["covenantId"]
83 gid = (await rpc.submit_transaction({"transaction": g, "allowOrphan": False}))["transactionId"]
84 print("genesis", gid, "covenant id", covenant_id)
85 current = coin(gid, 0, 5 * 100_000_000, lock_with(0), daa, covenant_id)
86 count = 0
87
88 # 2. An update. The counter coin and a wallet coin for the fee go in.
89 # The new counter coin, bound to the same covenant ID, and the change come out.
90 def update(step, new_count, fee_coin, cov_id):
91 def build(unlock):
92 return Transaction(1,
93 [spend(current, address_from_script_public_key(lock_with(count), NET).to_string(),
94 counter_unlock(count, step)),
95 spend(fee_coin, me, unlock, budget=11)],
96 [TransactionOutput(5 * 100_000_000, lock_with(new_count), CovenantBinding(0, Hash(cov_id))),
97 TransactionOutput(fee_coin["utxoEntry"]["amount"] - fee, my_lock)],
98 0, NATIVE, 0, "", 0)
99 return signed(build, 1)
100
101 attempts = [("cheat, add 11", 11, 11, covenant_id), ("cheat, add 5 but write 6", 5, 6, covenant_id),
102 ("cheat, made-up covenant ID", 5, 5, "42" * 32), ("honest, add 5", 5, 5, covenant_id),
103 ("honest, add 3", 3, 8, covenant_id)]
104 fee_coins = iter(wallet[1:])
105 fee_coin = next(fee_coins)
106 for label, step, new_count, cov_id in attempts:
107 try:
108 r = await rpc.submit_transaction({"transaction": update(step, new_count, fee_coin, cov_id),
109 "allowOrphan": False})
110 print(label, "-> accepted", r["transactionId"])
111 current = coin(r["transactionId"], 0, 5 * 100_000_000, lock_with(new_count), daa, covenant_id)
112 count = new_count
113 fee_coin = next(fee_coins)
114 except Exception as error:
115 print(label, "-> rejected:", str(error).split(": ", 2)[-1][:110])
116 await rpc.disconnect()
117
118asyncio.run(main())1genesis fca4cb94... covenant id 32e5ee96ca5944c781a3e2123960f31eac4...
2cheat, add 11 -> rejected: script ran, but verification failed
3cheat, add 5 but write 6 -> rejected: script ran, but verification failed
4cheat, made-up covenant ID -> rejected: input #0 and outputs with covenant id 4242... do not correspond to the expected genesis hashing
5honest, add 5 -> accepted df585988...
6honest, add 3 -> accepted b89258d2...The first two cheats are rung 2 at work. The script read the new coin, saw a step over 10 or a count that didn't add up, and refused. The honest updates went through, each spending the coin the one before had created, all while nothing was being mined. The counter's 5 KAS passed from coin to coin untouched, because a separate wallet coin paid each fee.
Two details in the code are new. Transactions that create or update covenants use version 1, which Toccata added. And in version 1, every input declares a compute budget for its script. One unit buys 10,000 script units, and every input gets 9,999 on top at no extra cost. The counter's checks fit in that allowance, so its input has a budget of 0.
A wallet input checks a signature, which costs 100,000 script units, so it needs a budget of at least 10. kaspa.news tried 9 on the devnet. Together with the allowance, that's 99,999 script units, and the node refused the transaction with "script units exceeded the amount committed in the input". With 10 it went through, and the programs use 11 to leave a margin. The signature doesn't cover the budget, so a different budget leaves the signature and the transaction ID as they were. That lets a wallet adjust the budget after signing. The script pricing page has the full price list.
Rung 3. The covenant ID says which coin is the real one
Look at the third cheat again. Without a covenant ID, anyone could create a fresh coin with the counter's script and a count of their choosing, and nobody could tell it from the real counter. Before Toccata, the workaround was to prove a coin's ancestry by carrying its parent and grandparent transactions along. KIP-20 moved that into consensus. A covenant gets its ID in its genesis transaction, calculated from the coin that pays for it and the coins it creates, and the network checks that every coin claiming the ID descends from that genesis.
In the program, GenesisCovenantGroup(0, [0]) says "input 0 authorises a new covenant, and output 0 belongs to it", and CovenantBinding(0, Hash(covenant_id)) attaches the ID to each new counter coin and names input 0 as its parent. The made-up ID never reached the script. The network refused it first, which is rung 3 at work.
Notice also that the counter's address changed with every update, because the count is part of the script. The covenant ID is what stays. Sutton's rule for identity is "cov id + template hash" (chat). The ID tells you which history a coin belongs to, and the template hash in the compiler's output tells you which code it runs, whatever the state.
There's one more thing an ID can't do on its own. It commits to the contract and its starting state, but someone who sees only the ID can't tell which contract and state those were. So publish a genesis proof. Sutton merged tools for this into Argent (#66), and he wants every stateful covenant to come with one.
kaspa.news made a proof for an earlier counter on the same network. The bootstrap file names the coin that paid for the genesis and the covenant output with its value and starting state.
1{
2 "authorizing_outpoint": {
3 "transactionId": "7b4e7dbf6818063e90633e64951d3f9bd51ec3bddf31ebfd66b480a7b830ef96",
4 "index": 0
5 },
6 "outputs": [
7 {
8 "index": 0,
9 "value": 500000000,
10 "abi_index": 0,
11 "contract": "Counter",
12 "runtime_state": { "count": { "kind": "int", "value": 0 } }
13 }
14 ]
15}1argentc genesis compose --sil-abi counter.json \
2 --bootstrap sil-genesis.json --out counter-genesis-proof.json
3argentc genesis verify counter-genesis-proof.json \
4 --covenant-id a805e75f3a034a2b2f3296d800531c9f823af48c5fa7379bd73d4dad7e7e643eargentc comes with Argent, described below. counter.json is the compiler's output, and the outpoint is the first input of your genesis transaction. Verification printed "covenant ID matches" and listed what it checked. Changing the starting count from 0 to 1 gave a different ID and a failed check, and so did checking against a wrong ID. Always take the ID you check against from a node, never from the person publishing the proof. Ethereum has source verification on Etherscan and Sourcify, and Solana has verified builds. On Kaspa, genesis proofs play that part, though explorers don't show them yet.
Rung 4. A proof checks work done off-chain
A covenant keeps state per coin, so when many users must change one shared state at the same moment, as in an order book or a game with a shared pot, a single covenant coin becomes a queue. The fourth rung handles that. In a based app, users send their actions as ordinary transactions and Kaspa puts them in order. The app runs them off-chain and settles back on Kaspa with a zero-knowledge proof, which a script checks. The proof is a small piece of data that shows the work was done correctly, so nobody has to run it again. Ethereum builders will see a likeness to rollups. The difference is that the order comes from Kaspa itself, not from a sequencer the app runs.
The pieces are on mainnet. KIP-16 added a single opcode, OpZkPrecompile, that checks Groth16 and RISC Zero proofs, and KIP-21 made blocks commit to the order of transactions lane by lane, so an app can prove what happened in its own lane. In SilverScript a proof check is one line, such as r0.g16.verify(journal_hash, proof, image_id), and the Rust crate kaspa-txscript-zk-sdk and the Python and JavaScript SDKs have helpers. The applications are early. kaspa.news covered the first working based app in the tic-tac-toe story and its limits in the workshop follow-up. vProgs are the longer-term design built from the same pieces. Even there, Sutton expects the money to sit in covenants.
How this differs from Ethereum and Solana
The ladder explains Kaspa on its own terms. Here's what it means if you're used to accounts.
There's nothing to deploy. A contract exists the moment coins are sent to its address, and several parties can create and enter one in a single transaction. Sutton put both properties side by side.
Contracts are light. The chain stores a commitment to the rules and state, and each transaction brings the data needed to check the next step. An Ethereum contract keeps permanent storage that costs gas to write, and a Solana account must hold a refundable deposit in proportion to its data. A Kaspa covenant pays for the bytes of its coin and the work of each spend.
Unrelated users never wait for each other. Each coin can be spent once, so two users who update the same coin at the same moment collide, and ten blocks a second doesn't change that, because the queue is at the coin. Solana has the same shape. Transactions list the accounts they will write, those touching different accounts run side by side, and a busy account becomes a queue. On both chains, good designs spread state across many places. Ethereum runs a block's transactions one after another, so it never runs them side by side in the first place.
Composition happens inside one transaction. On Ethereum, one contract calls another while it runs. On Kaspa no script calls another. Instead, several covenant coins are spent in the same transaction, each checks the others, and either every step happens or none does. Sutton calls this single-transaction atomicity, and it's what makes exchanges and lending possible.
If you know Ethereum, this is where its pieces went.
msg.senderbecomes a signature check in the script, such ascheckSig(ownerSig, owner).- Contract storage becomes the state slot inside the coin's script.
- A contract call becomes a transaction that spends the contract's coin with the right arguments.
- Deployment becomes sending coins to the contract's address.
- Events and logs don't exist. Apps read the accepted transactions instead, as the toolbox section shows.
- Gas becomes mass, and there's no nonce. You choose which coins to spend.
- Upgrades happen only if the contract itself allows a new script. The counter doesn't, so its rules are fixed forever.
- Reentrancy in the Ethereum sense doesn't apply, because no script calls another while it runs. The risk moves elsewhere. Whoever builds the transaction chooses every other input and output, so a contract must check every output it cares about.
- Solidity reverts on overflow. SilverScript doesn't, so every bound needs its own
require.
As a rough map of tools, Solidity becomes SilverScript, a system of contracts becomes Argent, ERC-20 becomes KCC-20, Hardhat or Foundry become silverc plus an SDK, Sepolia becomes testnet 10 and a local Anvil chain becomes a devnet. On Solana, programs map to SilverScript and Argent, SPL tokens to KCC-20 and solana-test-validator to a devnet.
Designing a real app
A counter is a toy. Real apps raise three questions.
Where does the state go? Spread it out. A token should keep one coin per holder, not one coin for everyone, so transfers between unrelated people never touch the same coin. Sutton thinks distributed state across many coins can enjoy parallelism and still "converge to a sufficiently approximate shared state" (post), and the hard part of a design such as an exchange is the one variable everyone needs, like a price.
How do contracts work together? With Argent. It's Sutton's language for apps made of several contracts, and IzioDev, who builds it with him, calls it "an actor-based language and compiler for building stateful, multi-contract and multi-app applications on covenant-native UTXO rails" (chat). Each actor becomes a SilverScript contract, and Argent writes the checks that let actors rely on each other inside one transaction. It has no release yet and changes every week, sometimes in ways that break code.
Start with argent-template, where ./setup installed the toolchain in about 17 minutes and ./argentc build ag/counter.ag compiled the sample. The playground has seven demos, from basic_counter to dex_asset, and all seven ran with Rust 1.94.1, which you need because 1.93.1 stops with "argent@0.1.0 requires rustc 1.94.0". The template says plainly that it "does not connect to a Kaspa network, manage a wallet, or submit transactions", so the transaction code is yours, as in rung 2.
How do tokens work? Through KCC-20, a convention every wallet and explorer can read. KCC-20 tokens are covenant coins that every node checks, and they have nothing to do with KRC-20, the older format whose balances are computed off-chain by indexers. A KCC-20 token is many covenant coins under one ID and one template, not one contract with a table of balances like ERC-20. Solana builders will find that familiar, since an SPL token also lives in one account per holder, and like Solana, a recipient needs a token coin before receiving (IzioDev). Because small coins are expensive, a transfer can borrow the recipient's existing token coin, spending it and recreating it with a larger balance, without their signature, as long as the asset and owner stay the same and the amount only grows.
The reference implementation by Sivan Helfer, written in Argent, is in kcc20-reference. The KCC-20 book walks through an example token made of two SilverScript contracts, a token and a minter, and calls them examples, not a production token standard. Its chapter on the token contract dates from before SilverScript 1.0, and the 1.0.0 compiler rejects that code as printed. The copy in the SilverScript repository, silverscript-lang/tests/examples/kcc20.sil, compiles.
Be honest with yourself about which kind of covenant you're building. Sutton sorts them into two.
A vault or escrow guards its owner's KAS. A token or registry carries state that many people rely on, and a mistake there spreads further. And for exchanges and lending, take his warning in the reply above seriously. They're possible, but "good designs require real effort and expertise".
Fees on one page
Every transaction pays its mass times a fee rate in sompi per gram, and the whole fee goes to miners. Ethereum charges gas and burns part of each fee, and Solana charges 5,000 lamports per signature plus an optional priority fee.
The minimum is node policy, not a consensus rule. Since Toccata, a node relays a transaction only if it pays at least 100 sompi per gram of its compute mass or twice its size in bytes, whichever is larger. Ask the node with getFeeEstimate instead of writing a number into your code. On a quiet network it returns 100.
What surprises people is storage mass. It makes creating small coins expensive and merging coins cost nothing, because every coin adds to the set that every node must keep. Paying 0.1 KAS out of a 10 KAS coin comes to just over 100,000 grams of storage mass, over the limit that wallets apply to one transaction, and the Python SDK refuses to build it with "Storage mass exceeds maximum". Paying 1 KAS the same way comes to about 10,000. So avoid tiny outputs and give every token coin enough KAS to be worth its place.
Covenant coins count double in size, because the covenant ID adds 32 bytes, and storage mass grows with the square of the size, so a covenant coin weighs four times as much as a plain coin with the same KAS (Sutton's note). A covenant coin created on its own needs about 0.4 KAS to stay under the limit.
For a sense of scale, the tested transactions weighed 2,036 grams for a payment, 1,828 for claiming from the lock, 8,029 for creating the counter and 6,391 for each counter update.
Your toolbox
- The node. Rusty Kaspa 2.1.0. Use the same version of every SDK as the node you talk to.
- SilverScript. The
silverccompiler, a VS Code extension,IzioDev.silverscript, and a command-line debugger in the repository that steps through a contract and runs whole transactions from a.test.jsonfile. Build the debugger from source.docs/TUTORIAL.mdis the best introduction, andUNDEFINED-BEHAVIOUR.mdis required reading before real money. - Python SDK.
pip install kaspa==2.1.0, with documentation and wallet examples. - JavaScript SDK. The file
kaspa-wasm32-sdk-v2.1.0.zipon the node release page, with examples for covenants, message signing, reading the chain and proofs. Don't install it from npm, where the packages are older than Toccata. - Rust. The node's crates on crates.io, such as
kaspa-wrpc-clientandkaspa-wallet-core. - kaspa-core. A payments library in plain TypeScript from the Zelcore team,
@runonflux/kaspa-core, for websites and payout services that would rather not ship WebAssembly. A 3 KAS payment built and signed with it was accepted by the devnet node. Its built-in client can't reach a devnet, its planner needsvirtualDaaScoreto see mined coins, and it refuses covenant coins by default, so it's for payments only. Its audit notes name no audit firm. - Public nodes. Pass
resolver=Resolver()instead of a URL, and the SDK picks a public node. It connected to a synced testnet 10 node from both Python and JavaScript. Public nodes are for development. An app people depend on should run its own. - Reading the chain. Apps follow accepted transactions with
getVirtualChainFromBlockV2, as the accepted transactions guide shows, because a transaction inside a block is final only once the chain accepts that block. A node keeps about 30 hours of blocks, so apps that need older history keep their own records or use an indexer. - Explorers. kaspa.stream and explorer.kaspa.org for mainnet, tn10.kaspa.stream for testnet 10.
- Wallets. Kaspa-NG for desktop and web, Kaspium for mobile and browser extensions such as KasWare and Kastle. There's no shared way for websites to talk to wallets yet. KIP-12 and KCC-12 propose one, and until they land, each wallet is its own integration.
From devnet to testnet to mainnet
Testnet 10 is the public test network, and it's the step between your devnet and real money. Get test coins from faucet-tn10.kaspanet.io in a normal browser, since it blocks scripts, and look up your transactions on tn10.kaspa.stream. You can also mine them, but only with your own synced testnet node. Syncing one took the test PC about four hours and 78 GB of disk, after which two CPU threads mined over 1,600 test KAS in seven minutes. Mined testnet coins are small, about 3 KAS each, which is why the counter program picks the biggest coins it has.
To move the scripts over, watch for one trap in the Python SDK. Functions that make addresses, to_address and address_from_script_public_key, take the network type "testnet". Everything that talks to the network, RpcClient, create_transactions and calculate_transaction_mass, takes the full name "testnet-10". Mixing them up fails with "Invalid network type" or "Missing network suffix". Then connect with resolver=Resolver(), or to port 17210 if you run your own testnet node. On mainnet both names are "mainnet".
With those changes, the three programs from rungs 1 and 2 ran on testnet 10 and behaved exactly as on the devnet. The payment of 25 KAS went through, in transaction 4e2192d5.... The fee was 0.011 KAS this time, because the wallet merged 50 small mined coins to pay it. The lock took 10 KAS, refused the wrong secret and released the coins to the right one. The counter was created, refused all three cheats and took the two honest steps, and after a few minutes its coin sat in the chain holding 8, under covenant ID 92c3c397.... A second public node, picked by the resolver, showed the same payment coin. The full IDs are in the testing notes at the end.
Before mainnet, go through this list.
- Every cheating test you can think of fails on devnet and again on testnet 10.
- Every number has an explicit bound in a
require, and every constructor value that should change the address is actually used, or the compiler may drop it. - Every output your contract cares about is checked by the contract itself.
- Time checks are used only to prove that a moment has passed, because a script can't prove that a moment hasn't passed yet. A time lock set to a date and time is checked against the median timestamp of recent blocks, which ran 134 seconds behind real time on mainnet when kaspa.news checked, so expect an unlock about two minutes after the moment you set.
- Your genesis proof is ready to publish.
- Your first deployment holds a small amount, and anything holding other people's money has had an independent review.
- Your keys never appear in a chat, an issue, a screenshot or a log.
Who has checked the code
"Audited" means someone independent of the authors examined the code and published what they found. As far as kaspa.news could find, that hasn't happened yet for the contract tools.
- The node is open source and runs mainnet. Release 2.1.0 enforced a strict arithmetic-safety rule across the code, which the developers did themselves. In June, the security firm Coinspect asked on GitHub whether the new zero-knowledge and sequencing code had been reviewed, pointing to its own audit work (#1058). Nobody has replied there.
- SilverScript calls itself experimental in its security policy, and no audit report is published. Report bugs privately through its GitHub security advisories page.
- Argent has no release. The team plans AI-assisted audit sweeps before the first one.
- The KCC-20 reference was reviewed by Michael Sutton, one of the convention's authors, by hand and with AI help. kaspa.news ran its three examples and all 60 of its tests, including the 6 conformance tests, with Rust 1.94.1, and everything passed. Tests written by the authors still aren't an independent audit.
- kaspa-core has strong tests against the node and reviewers it doesn't name.
None of that makes the tools bad. It means the checking is still done mostly by the people who wrote them, and your contract deserves its own review before it holds anyone's money.
Still missing
- A first release of Argent and final status for the KCC conventions.
- A way to list every coin of a covenant ID (#1128) and to page through large sets of addresses (#991).
- Current JavaScript SDK packages on npm (#1075).
- A shared wallet API, explorers that show genesis proofs, and docs updated for SilverScript 1.0.
- A higher-level SDK that builds contract transactions for you. Sutton has said that making this accessible "via appropriate SDKs and toolchains" is "top priority and being worked on by several ppl" (post). Until it lands, you write the transactions yourself, as above.
- Independent audits.
Questions go to the Kaspa Core R&D chat on Telegram, where the people building all of this answer, and bugs go to GitHub issues in rusty-kaspa, silverscript and argent. Conventions are discussed on kas-smiths.org.
How this guide was tested
Everything ran on one Linux PC against the rusty-kaspa 2.1.0 release, on a private devnet and on public testnet 10, with the Python SDK kaspa 2.1.0, silverc 1.0.0, the JavaScript SDK 2.1.0 on Node.js 26.7.0, Argent at commit 9a9f4b1 with Rust 1.94.1, the KCC-20 reference at commit c8a0871 and @runonflux/kaspa-core 1.0.0. On 11 October the whole devnet part was run once more from an empty folder, downloading the tools and copying every command and program from this guide exactly as printed, and every step worked. The programs in rungs 1 and 2 were run exactly as printed on the devnet, and on testnet 10 with only the network names and the node address changed. Connections to public testnet 10 were also tested from JavaScript. No real KAS was used.
A day later, on 12 October, steps 1 to 7 and the programs of rungs 1 and 2 were run again from an empty folder, exactly as printed, and every step worked, with the first coins spendable after about three minutes. On 13 October kaspa.news checked the compute budget minimum from rung 2 on the devnet node and the storage mass example from the fees section with the Python SDK, ran the KCC-20 reference examples and tests, and compiled both versions of the KCC-20 token contract with silverc 1.0.0.
Testnet 10 transactions, 11 October 2026. The counter and its covenant open on kascov, an explorer for covenant coins that shows the whole life of the counter. The payment and the lock are ordinary transactions, so they open on the kaspa.org testnet 10 data service.
- Payment 4e2192d5…5f271e
- Lock funded 1be9fa81…dd2d57, claimed 46ef619f…2bf2bc
- Counter created 95a5fba3…1f2f0f, updated def246e6…22008d and 874f9682…4cb93a
- Counter covenant ID 92c3c397…fe64f9
The figures will move with new releases, so check the version you're on before relying on any of them.
