⚠ Runtime note (2026-07): the
stackvmruntime andexecnode/vm.pywere removed — the only runtime today is the field-native zkVM (execnode/zkvm.py,runtime: "zkvm",DEFAULT_RUNTIME="zkvm"), and contracts live atexecnode/games/*.py, notexecnode/contracts/*.json. The blob envelope, submission, and read paths in this doc are still exactly current; only the VALUE-opcode sections below (thestackvmprimitives) and theexecnode/contracts/*.jsonexample cids are legacy. For authoring a live contract, read a shipped game (e.g.execnode/games/coinflip.py) andzk-execution-proofs.md.
Every instruction the NADO execution layer understands, how to submit it, and how to read the result.
This is the operational companion to rollups-and-settlement.md (the whole
architecture), execution-layer.md (why the shape), and privacy.md
(shielded pool). Source of truth: execnode/state.py (apply_blob) and execnode/execnode.py (the
/exec/* read API + the L1 tail loop). Client builders live in ops/transaction_ops.py.
An instruction is a blob: an opaque-to-L1 JSON object carried by an ordinary L1 transaction whose
recipient is the reserved name "blob". L1 orders and stores the blob and burns its fee — it never
decodes it. The execution node tails L1, and for every FINALIZED block it replays that block's
exec-relevant txs in block order, dispatching each blob's payload["op"] through
ExecState.apply_blob(payload, sender, txid).
Key rules:
- Ordering. Blobs apply in L1 block order, and within a block in tx order. This total order is the only thing that makes execution deterministic across nodes.
- Finality. Only FINALIZED blocks mutate persistent exec state (
tail_loopreads L1/statusfinalized_heightand never applies past it), so the persistent cursor never has to handle a reorg. - Namespace. Each blob targets a rollup namespace via
payload.ns(default"default"). A node only applies a blob for a namespace it runs; others are ignored.bridge/shield/dividend are default-layer features. - Never raises.
apply_blobis wrapped in try/except and returns a short human string. A malformed, unknown, or reverting blob is a no-op — it returns"skip: …"or"… -> revert (no-op)"and mutates nothing. There is no error surfaced to the submitter; the fee is still burned by L1. senderis the L1 tx sender (its signedsenderaddress).txidis the L1 txid, used as the default deploy nonce.- Provisional reads. Any
/exec/*read endpoint accepts?provisional=1(alsotrue/yes), returning a fast pre-finality view: a clone of the finalized state with the unfinalized L1 tail (up to 64 blocks) speculatively applied, so a dApp sees a just-included move within ~one block (~6s) instead of a whole finality window. It is display-only and self-heals on reorg. Settlement and all Merkle proofs read the finalized (plain, no-provisional) state.
Some exec-relevant actions are plain L1 txs with a reserved recipient, handled by the tail loop directly
(see _apply_block in execnode/execnode.py), not by apply_blob:
| L1 recipient | Effect on exec state | Builder |
|---|---|---|
bridge |
credits sender's exec bridge balance by amount (credit_deposit) |
construct_bridge_deposit_tx |
bridge_withdraw |
L1 verifies a Merkle proof against the settled root and releases escrow (the exit bridge_withdraw blob recorded the leaf) |
construct_bridge_withdraw_tx |
xmsg |
L1-verified cross-rollup DELIVERY → folded into the receiver ns inbox (apply_xmsg) |
construct_xmsg_tx |
shield |
shielded DEPOSIT → adds note(s) to the pool (apply_shield / apply_field_shield) |
— |
settle |
bonded-validator state-root attestation | construct_settle_tx |
Build a signed L1 blob tx with construct_blob_tx(keydict, payload, target_block, fee)
(ops/transaction_ops.py):
from ops.transaction_ops import construct_blob_tx
tx = construct_blob_tx(keys, {"op": "deploy", "code": {...}}, target_block, fee)
# -> {"sender":..., "recipient":"blob", "amount":0, "data":<payload>, "fee":..., "signature":...}
# POST tx to L1 /submit_transactionThe payload rides verbatim in the tx's data field; recipient is "blob" and amount is 0. Submit the
signed tx to L1's /submit_transaction. Fee is per-byte (size-capped by L1). The Coin Flip dApp does not sign
locally — it delegates signing to the wallet (SSO), which builds the same blob tx.
Every op below is the data/payload object. All accept an optional "ns" field (default "default").
Deploy a contract to a pluggable runtime.
| field | req | meaning |
|---|---|---|
op |
yes | "deploy" |
code |
yes | contract code (map of method → bytecode; must pass runtime.validate_code) |
runtime |
no | runtime name, default runtimes.DEFAULT_RUNTIME (zkvm) |
nonce |
no | deploy nonce; defaults to the L1 txid |
abi |
no | non-consensus UX metadata {method: {args, doc}} (must be a dict, else ignored) |
upgradable |
no | opt-out immutability flag, default true. false deploys a contract that can never be upgraded — permanently immutable from block zero. See §9.1. |
ns |
no | namespace |
The contract id is deterministic: cid = blake2b_hash(["deploy", sender, code, nonce])[:32]
(ExecState.contract_id). Because it depends only on known inputs, a deployer knows its cid before the
blob lands. If code contains a "constructor" method it is run at deploy; if it reverts, the contract
deploys with empty storage.
Skips if: payload not a dict; unknown runtime; code fails validation (raises ZkVMError, caught);
cid already exists.
{"op":"deploy","code":{"constructor":"…","get":"…","set":"…"},"runtime":"zkvm",
"upgradable":true,
"abi":{"set":{"args":["key","value"],"doc":"store a value"}}}Invoke a method, persisting storage on success. Optionally escrow NADO into the contract for the call.
| field | req | meaning |
|---|---|---|
op |
yes | "call" |
contract |
yes | target cid |
method |
yes | method name to run |
args |
no | argument list (default []; must be a list). Each arg is an int in the Goldilocks field or an address string (digested to a field element at the call boundary). Up to 1024 args: the first 8 preload registers r0..r7, and the ARG opcode reaches all of them by dynamic index — variadic inputs (merkle proofs, batches) are first-class, no packing needed. |
value |
no | raw NADO to escrow from the caller's bridge INTO the contract for this call (int >= 0, bool rejected; default 0) |
ns |
no | namespace |
Runs method on the contract's runtime with sender as caller. On success the returned storage replaces the
contract's storage; on revert it is a no-op ("… -> revert (no-op)").
Value / escrow semantics. When value > 0, that many raw NADO are debited from bridge[sender] and
credited into the contract's own bridge balance (bridge[cid]) before the method runs, so the VALUE
opcode reflects it and PAY can draw on it. A revert refunds the escrow exactly — no NADO is created or
lost. Any PAY payouts the method schedules are applied from the contract's balance after it returns; a
call whose total payouts exceed the contract's balance reverts (and refunds), so a contract balance can
never go negative and no NADO is minted. This makes a contract able to hold and move real bridged NADO — a
generic escrow/staking primitive, not specific to any one dApp (see the Coin Flip example, §5).
Skips if: no such contract; args is not a list; value not a non-negative int; unknown runtime;
insufficient bridge balance for value.
{"op":"call","contract":"<cid hex>","method":"set","args":["hello",42]}
{"op":"call","contract":"<cid hex>","method":"open","args":[7,<commit int>],"value":100000}VM value opcodes (execnode/vm.py, stackvm runtime) — the primitives a contract uses to interact with
escrow: VALUE pushes the NADO escrowed with THIS call; PAY pops amount then to and schedules a payout
of amount raw NADO from the contract's escrow to to (max 16 payouts/call; amount == 0 skipped; to must
be a non-empty string); CURSOR pushes the current L1 block height (for deadlines). MSTORE stores an int
or string (strings let a contract store addresses); a 0/empty value deletes the key. run() returns
(ok, return_value, new_storage, payouts).
Replace a contract's code (and optionally runtime/abi), preserving its cid and storage.
| field | req | meaning |
|---|---|---|
op |
yes | "upgrade" |
contract |
yes | target cid |
code |
yes | new code (validated before install) |
runtime |
no | new runtime, default the contract's current runtime |
abi |
no | new abi (installed only if a dict) |
ns |
no | namespace |
Ownership rule: only the current owner may upgrade — sender must equal contracts[cid].deployer.
Immutability rule: the upgrade is refused if the contract is locked — i.e. it was deployed with
{"upgradable": false} or later locked. This is the mainnet trust model: a contract can be made permanently
immutable, and until it is, its owner may iterate freely. See
§9.1.
Skips if: no such contract; sender is not the owner; the contract is locked; unknown runtime;
new code fails validation.
{"op":"upgrade","contract":"<cid hex>","code":{"constructor":"…","set":"…"}}Permanently renounce upgradability. A one-way switch: after lock, the contract's code can never be
changed again — every future upgrade is refused. Storage, code, and cid are untouched; only the
upgradable flag flips to false. This is the on-chain primitive that lets a deployer prove immutability
to users (the same guarantee an immutable-from-birth {"upgradable": false} deploy gives, but reached after
a period of iteration). Idempotent — locking an already-locked contract is a no-op.
| field | req | meaning |
|---|---|---|
op |
yes | "lock" |
contract |
yes | target cid |
ns |
no | namespace |
Rule: only the current owner may lock — sender must equal contracts[cid].deployer. There is no
unlock — immutability is irreversible by design.
Skips if: no such contract; sender is not the owner.
{"op":"lock","contract":"<cid hex>"}Hand a contract's ownership — the deployer right (who may upgrade or transfer_contract it) — to another
address. Code, storage, and cid are unchanged; only contracts[cid].deployer is reassigned. Lets a contract
be handed to a new maintainer without redeploying.
| field | req | meaning |
|---|---|---|
op |
yes | "transfer_contract" |
contract |
yes | target cid |
to |
yes | new owner address (non-empty string) |
ns |
no | namespace |
Rule: only the current owner may transfer — sender must equal contracts[cid].deployer. After transfer,
the new owner alone can upgrade/transfer_contract; the old owner can no longer.
Skips if: no such contract; sender is not the current owner; to missing/empty.
{"op":"transfer_contract","contract":"<cid hex>","to":"ndo…newowner"}Deposits are L1-side (send an L1 tx to recipient bridge with an amount; the tail loop credits the
sender's exec bridge balance via credit_deposit). Exits are blobs that burn exec balance and record a
provable withdrawal leaf; after the carrying state_root settles on L1 you claim the L1 coins with a
Merkle proof.
| field | req | meaning |
|---|---|---|
op |
yes | "bridge_withdraw" |
amount |
yes | positive int raw units (bool rejected) |
ns |
no | namespace |
Burns amount from bridge[sender], increments wd_nonce, records
withdrawals[str(nonce)] = {"addr": sender, "amount": amount}. Fetch the proof from
/exec/withdrawal_proof?nonce= and submit it to L1's bridge_withdraw recipient (via
construct_bridge_withdraw_tx) once settled. Once that L1 claim FINALIZES (nullifier burned),
every exec node GCs the record (drop_claimed) — exit records don't accumulate in state_root.
Skips if: amount not a positive int; bridge balance < amount.
{"op":"bridge_withdraw","amount":100000}Collect the sender's whole accrued presence-dividend (see presence-dividend.md).
| field | req | meaning |
|---|---|---|
op |
yes | "collect_dividend" |
ns |
no | namespace |
Burns the entire dividend[sender] into dividend_withdrawals[str(dw_nonce)] = {"addr", "amount"}. Claim on
L1 with the proof from /exec/dividend_proof?nonce= after settlement (fee-exempt dividend_withdraw tx);
the record is GC'd once the finalized claim burns its nullifier (same pattern as bridge_withdraw).
Skips if: no accrued dividend for sender (amount <= 0).
{"op":"collect_dividend"}Coin Flip is not a native module — there is no coinflip-specific op or API. It is an ordinary on-chain
contract (execnode/contracts/coinflip.json, runtime stackvm) exercised entirely through the generic
call/view/upgrade surface, and it is the reference example of the VALUE/PAY escrow pattern (§3):
stakes are real bridged NADO escrowed into the contract via call's value, and the pot is paid out via PAY.
It is deployed at cid = 7ee95a0abd6e00d12edc3bf39f4c8f2d (node-owned, so upgradable by the node via the
upgrade op). game is an integer id (used as the storage key). The reveal window is 1000 L1 blocks
(CURSOR + 1000 deadline). All methods are called with the generic call op:
| method | args | value | effect |
|---|---|---|---|
open |
game, commit |
= stake |
open a fresh game (slot 1), escrow the stake as the pot; commit = HASH(secret) |
join |
game, commit |
= stake |
join as slot 2 (stake must equal the opener's); adds to the pot; sets deadline = CURSOR + 1000 |
reveal1 / reveal2 |
game, secret |
0 |
reveal your secret; reverts unless HASH(secret) matches your stored commit |
settle |
game |
0 |
after both reveal, pay the whole pot to the winner via PAY — result = HASH(s1+s2) % 2 (0 → slot 1, 1 → slot 2) |
claim |
game |
0 |
after the reveal deadline: the lone revealer takes the pot by forfeit, or (nobody revealed) each stake is refunded |
Because every method reverts on any bad precondition (wrong stake, wrong secret, wrong turn, double-join,
already settled), a losing or absent player can only stall, never steal; the value-escrow refund and the
"payouts ≤ contract balance" rule (§3) guarantee no NADO is minted or lost.
The Coin Flip dApp (coinflip.nadochain.com) reads game / lobby / scoreboard by deriving them from the
contract's storage maps via the generic GET /exec/contract endpoint (§8) — there is no dedicated read API.
{"op":"call","contract":"7ee95a0abd6e00d12edc3bf39f4c8f2d","method":"open","args":[7,<commit int>],"value":100000}
{"op":"call","contract":"7ee95a0abd6e00d12edc3bf39f4c8f2d","method":"reveal1","args":[7,<secret int>]}
{"op":"call","contract":"7ee95a0abd6e00d12edc3bf39f4c8f2d","method":"settle","args":[7]}Roulette is the same story as Coin Flip — not a native module, no roulette-specific op or API — but it
shows the escrow pattern extended to a house-banked, fixed-odds game. It is an ordinary on-chain contract
(execnode/contracts/roulette.json, runtime stackvm) deployed at cid = 186ebadb975794e2ed7eeb1c7b5115a5
(node-owned, upgradable), exercised entirely through the generic call/view/upgrade surface.
It is peer-banked: each game is two seats — a bank (posts a bankroll, commits a secret) and a
bettor (stakes a bet on a set of table numbers, commits a secret). One shared spin
result = HASH(bankSecret + bettorSecret) % 37 (0..36) is fair for the identical reason Coin Flip is: neither
secret is revealed until both are committed. commit = HASH(secret), reveal window 1000 L1 blocks.
Universal payout rule. A bet is just the set of numbers it covers; a winning bet returns
stake × (36 ÷ count), where count is how many numbers it covers (straight 1→36×, split 2→18×, street
3→12×, corner 4→9×, line 6→6×, dozen/column 12→3×, even-money 18→2×). With 37 pockets this is the
exact single-zero house edge (1/37 ≈ 2.70%) for every bet — the contract never needs to know bet types.
The covered set is passed as 18 fixed slots (n0…n17), padded with a sentinel (99); count is derived
on-chain (so coverage can't be understated), and each covered number n is recorded at cov[game*37+n].
| method | args | value | effect |
|---|---|---|---|
open |
game, bankCommit |
= bankroll |
bank a fresh table (seat 1), escrow the bankroll |
join |
game, betCommit, n0…n17 |
= stake |
bet at the table (seat 2): record the covered set + count; reverts unless the bankroll covers the max win (bankroll ≥ stake × (36÷count − 1)); sets deadline = CURSOR + 1000 |
reveal1 / reveal2 |
game, secret |
0 |
bank / bettor reveal; reverts unless HASH(secret) matches the stored commit |
settle |
game |
0 |
after both reveal: spin r = HASH(s1+s2) % 37; if r is covered, PAY the bettor stake × 36÷count from the bankroll, else sweep the stake into the bank. Stores ro[game]=r+1, wn[game]=win |
claim |
game |
0 |
after the deadline (not both revealed): a stalling bank pays the bettor their max win; a stalling bettor forfeits the stake to the bank; if neither revealed, both are refunded |
cancel |
game |
0 |
the bank reclaims its bankroll from a table nobody joined (nn==1) |
Each bank escrows only its own table's bankroll and receives that table's exact result (bankroll ± net)
to its bridge balance — withdrawable to L1 via bridge_withdraw (§4). House winnings are therefore returned
fairly to whoever funded that table, with no shared pool and no trust. The Roulette dApp
(roulette.nadochain.com) derives table / lobby / scoreboard state from GET /exec/contract (§8); there is no
dedicated read API. The build + full test vector is tests/test_roulette_contract.py.
{"op":"call","contract":"186ebadb975794e2ed7eeb1c7b5115a5","method":"open","args":[7,<bankCommit>],"value":5000000000000}
{"op":"call","contract":"186ebadb975794e2ed7eeb1c7b5115a5","method":"join","args":[7,<betCommit>,17,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99,99],"value":100000000000}
{"op":"call","contract":"186ebadb975794e2ed7eeb1c7b5115a5","method":"settle","args":[7]}Sports Bet (execnode/games/bet.py, runtime zkvm) is the first example contract whose outcome is not
derivable from chain randomness — it settles on a real-world result.
What "parimutuel" means (plain language). All money bet on a match goes into one shared pot. Nobody offers you odds and nobody takes the other side of your bet — you bet against the other bettors. When the result is posted, everyone who picked the winning outcome splits the whole pot in proportion to what they put in:
your payout = your_stake × total_pot ÷ winning_side's_pool
Example: 800 NADO is bet on Arsenal, 700 on Chelsea (pot 1500). Arsenal wins → each Arsenal backer gets
their stake × 1500/800 ≈ 1.87×; Chelsea backers get nothing. The "odds" shown in the UI are just the
live pot ratio and move as people bet — exactly like a racetrack tote board (that's where the word comes
from: pari mutuel, French for "mutual bet", invented for horse racing in 1867). Because the pot only
redistributes, the contract never mints, never profits, and can never owe more than it holds; payouts
are pull-based (each bettor claims their own share), so a market scales to any number of bettors.
Per-market resolvers. A blockchain can't see a football score, so each market names its own resolver
set at creation — up to 3 addresses with an M-of-N threshold (each resolver votes once; the first
outcome to reach the threshold finalizes). Naming nobody makes the creator the sole resolver. Markets are
permissionless — anyone can list one. Bettor protections: a resolver can void(m) a postponed match
(every stake refunds 1:1); once the market's deadline passes anyone may void it (a vanished resolver
can't strand the pot); and a posted winner with zero backers auto-voids instead of resolving to an
unpayable pool.
zkVM data model. Market metadata (title + outcome labels, source name, event id) are string args —
digested at the call boundary, stored as digests, resolved back to the original text by decode_view
("hash on-chain, text in the transaction"). Money is tracked in UNITs of 10^4 raw (stakes must be UNIT
multiples) so stake×pot stays inside the DIVMODW soundness window; a market's pot caps at 2^31 UNITs.
Per-user positions live in alghash-keyed slots — the frontend reads them through the read-only views
claimable_of(m, addr) / stake_of(m, i, addr) / total_of(m, addr) / claimed_of(m, addr) /
vote_of(m, addr) via GET /exec/view.
Methods: create_market(m, nout, lock, deadline, desc, source, ev, thr, r1, r2, r3) (11 args — they ride
the ARG indexed-args bus; desc is a \n-joined blob — title then one label per outcome; lock/
deadline are wall-clock epoch seconds, never block heights; pass 0 for empty resolver slots),
bet(m, outcome) (+value), resolve(m, outcome), void(m), claim(m). Outcomes are integers
0..nout-1 everywhere. The pro-rata claim division is a single DIVMODW (wide-divisor divmod). The full
scenario suite (pro-rata math, resolver gating, void/deadline refunds, auto-void, 2-of-3 panels, split
votes, double-claim guards, proofs of create_market and claim) is in tests/test_games_e2e.py.
{"op":"call","contract":"<bet cid>","method":"create_market","args":[770077,3,<lockEpoch>,<deadlineEpoch>,"Arsenal vs Chelsea\nArsenal\nDraw\nChelsea","thesportsdb","2052744",0,0,0,0]}
{"op":"call","contract":"<bet cid>","method":"bet","args":[770077,0],"value":100000000000}
{"op":"call","contract":"<bet cid>","method":"resolve","args":[770077,0]}
{"op":"call","contract":"<bet cid>","method":"claim","args":[770077]}Commit a cross-domain message into the outbox (committed in state_root, provable via
/exec/outbox_proof). The outbox is keyed by a persisted monotonic seq (never reused); a message
is GC'd once its finalized xmsg delivery burns the (from_ns, seq) L1 nullifier.
| field | req | meaning |
|---|---|---|
op |
yes | "emit" |
to_ns |
yes | non-empty string: destination namespace |
data |
no | arbitrary payload |
ns |
no | source namespace |
Appends {"seq": len(outbox), "from": sender, "to_ns": to_ns, "data": data} (append-only; seq == index).
This blob only commits the message. Delivery is separate: a consumer verifies the outbox proof against
the emitter's settled L1 root, then submits an xmsg L1 tx (construct_xmsg_tx, recipient xmsg) that
L1 verifies and folds into the receiver ns's inbox (apply_xmsg). See
rollups-and-settlement.md §7.4.
Skips if: to_ns is not a non-empty string.
{"op":"emit","to_ns":"myrollup","data":{"kind":"ping","n":1}}Shielded deposits are L1-side (recipient shield). The two shielded-transfer instructions are blobs;
they carry proofs/commitments and are verified by the pool. Full detail: privacy.md.
Phase-2 field-native join-split (full STARK proof from the delegated prover). The proof bundle rides as an opaque JSON string so its large field ints survive JSON round-trips.
| field | req | meaning |
|---|---|---|
op |
yes | "field_transfer" |
bundle_json |
one of | the bundle as a JSON string (preferred; big ints preserved) |
bundle |
one of | the bundle as a JSON object |
proof_da |
no | DA commitment; the tail loop resolves the bundle from DA before applying (block stalls if unavailable) |
ns |
no | namespace |
Applied via apply_field_transfer. Skips if: bundle_json unparsable; bundle not a dict.
Phase-1 join-split / unshield against the shielded pool.
| field | req | meaning |
|---|---|---|
op |
yes | "shielded_transfer" |
public |
yes | public inputs dict (root, nullifiers, out_commitments, public_value, fee, optional withdraw_addr) |
proof |
yes | proof dict |
ns |
no | namespace |
Verified + applied by apply_transfer (double-spend + value conservation checked). If
public.public_value < 0 the coins leave the pool (unshield): a provable exit
unshield_withdrawals[nonce] = {"addr": withdraw_addr, "amount": -public_value} is recorded for L1 to release
from SHIELD_ESCROW against the settled root (claim proof at /exec/unshield_proof?nonce=).
Skips if: public/proof not dicts; verifier rejects; unshield missing withdraw_addr.
All are GET /exec/* on the exec node. All namespaced endpoints accept ?ns= (default default) and
?provisional=1 (pre-finality, display-only). Proof endpoints return state_root alongside the proof and
must be read from the finalized state for a valid claim.
| path | purpose | key query params |
|---|---|---|
/exec/root |
node summary: state_root, cursor, contract count, L1 url |
ns, provisional |
/exec/settlement |
per-ns (cursor, state_root), settle flags/cadence, all served namespaces |
ns, provisional |
/exec/contracts |
contract list (cid, deployer, methods, runtime, abi); storage omitted | ns, deployer, prefix, limit (default 100, max 500), provisional |
/exec/contract |
one contract in full incl. entire storage; 404 if unknown | cid, ns, provisional |
/exec/view |
read-only method call (storage never persisted); result null on missing/revert |
cid, method, args (JSON list), ns, provisional |
/exec/outbox |
cross-domain outbox messages {seq, from, to_ns, data} |
ns, provisional |
/exec/outbox_proof |
Merkle proof that outbox seq is in the ns state_root |
ns, seq |
/exec/inbox |
messages delivered to this ns (from L1-verified xmsg) |
ns, provisional |
/exec/bridge |
all exec bridge balances + recorded withdrawal records | — |
/exec/withdrawal_proof |
Merkle proof for a bridge_withdraw record vs current state_root |
nonce |
/exec/dividend |
accrued presence-dividend (with ?address= also pending withdrawals) |
address |
/exec/dividend_proof |
Merkle proof for a collected dividend withdrawal | nonce |
/exec/unshields |
pending unshield exits for an L1 ?addr= |
addr |
/exec/unshield_proof |
Merkle proof for an unshield exit vs current state_root |
nonce |
/exec/shielded |
phase-1 pool status (root, note/nullifier counts, recent anchors) | — |
/exec/field_shielded |
phase-2 field-pool status; ?cm= also a commitment's position |
cm |
/exec/field_leaves |
full field-pool commitment list (build a Merkle path on-device) | — |
/exec/examples |
starter contract library (contract_lib.LIBRARY) |
— |
/exec/runtimes |
available runtimes + default | — |
/exec/prove_transfer, /exec/prove_transfer2 |
POST delegated STARK provers (return bundle_json; never apply) |
POST body = secret witness |
(There is also a /da/* data-availability API — publish/fetch erasure-coded proof objects by commitment.)
- deploy — pick a runtime, submit
{op:"deploy", code, …}. The cid isblake2b_hash(["deploy", sender, code, nonce])[:32], deterministic and knowable before the blob lands (nonce defaults to the L1 txid). Aconstructor, if present, runs at deploy; a reverting constructor yields empty storage. Pass{"upgradable": false}to deploy immutable from birth (see §9.1). - call —
{op:"call", contract, method, args}mutates storage on success, no-ops on revert. Use/exec/viewfor read-only calls. - upgrade —
{op:"upgrade", contract, code}replaces code but keeps the cid and storage. Owner-only (sender == deployer) and refused once the contract is locked (see §9.1). - lock —
{op:"lock", contract}permanently renounces upgradability (one-way; no unlock). Owner-only. - transfer_contract —
{op:"transfer_contract", contract, to}hands ownership (the upgrade/lock/transfer right) toto. Owner-only; code, storage, and cid are unchanged.
Because every write is a blob ordered by L1, contract state is a pure function of the finalized blob stream —
identical on every exec node and committed in state_root.
NADO contracts are mutable by their owner by default, and immutable once locked. This is a deliberate
middle path between "always mutable" (convenient, but users must trust the owner forever) and "always
immutable" (trustless, but unshippable — you can never fix a bug). Every contract carries one boolean,
upgradable, and the lifecycle around it is:
| State | How you get there | upgrade allowed? |
Reversible? |
|---|---|---|---|
| Upgradable (default) | deploy with no flag, or {"upgradable": true} |
✅ owner only | — |
| Immutable from birth | deploy with {"upgradable": false} |
❌ never | ❌ one-way |
| Locked after iteration | any upgradable contract → lock |
❌ never | ❌ one-way |
The design intent for mainnet:
- Ship, iterate, then commit. Deploy upgradable, fix bugs and tune parameters through
upgrade(the cid and all user state are preserved across every upgrade), and when the contract is battle-tested,lockit. From that block on, users have a cryptographic guarantee — anchored instate_root— that the code can never change, exactly as if it had been immutable from day one. - Or commit up front. A contract that must be trustless from its first transaction (a token, a vault, a
game bank) deploys with
{"upgradable": false}and skips the mutable phase entirely. - Immutability is one-way. There is no
unlockop and no governance override. Onceupgradableisfalseit staysfalsefor the life of the chain — that irreversibility is the whole point. - Ownership is separable from mutability.
transfer_contracthands the owner right to a new maintainer without touching the lock state. A locked contract stays locked no matter who owns it; transferring an upgradable contract hands the new owner the ability toupgradeand tolock.
Reading the flag. /exec/contract?cid=…&ns=… returns "upgradable": <bool> alongside the contract's
code/runtime/deployer, so a wallet or explorer can show users whether a contract can still change under them.
The deploy/lock log lines also mark a locked contract (… (zkvm, LOCKED) …).
Enforcement is in ExecState._apply_blob_inner (state.py): deploy records upgradable
(payload.get("upgradable", True) is not False); lock flips it to false for the owner; upgrade refuses
with skip: contract … is locked (immutable) when the flag is false. Because all three are ordinary
L1-ordered blobs, the lock state is consensus state — every exec node agrees on it and it is committed in
state_root.