Locking & Redeeming¶
ZkScriptBuilder is a
staged builder: the redeem script must be bound to a program before
a proof can be supplied, and neither step can happen twice. The native
Rust SDK enforces that order at compile time; the Python binding mirrors
it at runtime — a call in the wrong state raises
ZkError instead of producing
a broken script.
commit_to_groth16(image_id)
unbounded ─────────────────────────────────► groth16-bounded
│ │
│ commit_to_succinct( │ finalize_with_groth16_proof(
│ image_id, control_id, hash_fn_id?) │ receipt, journal_hash)
▼ ▼
succinct-bounded ──────────────────────────► FinalizedR0Script
finalize_with_succinct_proof( (builder is consumed)
receipt, journal)
You must finalize with the matching proof form: a Groth16 finalize on
a Groth16-bounded builder, a succinct finalize on a succinct-bounded
one. repr(builder) shows the current state.
Byte arguments — image ids, journal hashes, receipts — accept a hex
string, bytes, or a list of ints throughout.
Creating a builder¶
ZkScriptBuilder.new_r0
is a static factory (there is no constructor — matching the WASM SDK, so
future non-RISC-Zero backends can add their own factories):
covenants_enabled(defaultTrue) selects the post-Toccata script limits. The sig script's pushes — the redeem script itself, and for succinct proofs the witness — exceed the pre-Toccata 520-byte element limit, so finalizing always fails when this isFalse; only passFalseto build fragments under pre-Toccata rules.sigop_script_unitsoverrides the script units charged per signature operation; omit it for the engine default.
Committing: the lock side¶
Committing binds the builder to a specific program and writes the verifier into the redeem script:
builder.commit_to_groth16(image_id) # Groth16
builder.commit_to_succinct(image_id, control_id) # succinct
The succinct form also takes a hash_fn_id (currently only
"poseidon2", which is the default when omitted). After a commit,
script() returns the
redeem script (hex) — hash it into the P2SH lock the usual way (see
Scripts → Wrapping in P2SH):
from kaspa import address_from_script_public_key, pay_to_script_hash_script
spk = pay_to_script_hash_script(builder.script())
p2sh_address = address_from_script_public_key(spk, "testnet")
Funds sent to p2sh_address are now locked behind the proof.
Finalizing: the spend side¶
Finalizing decodes the receipt, assembles the spending script, and consumes the builder:
finalized = builder.finalize_with_groth16_proof(receipt, journal_hash)
finalized.redeem_script # the commit script — same bytes script() returned
finalized.sig_script # the proof-bearing spending script
FinalizedR0Script
holds both scripts, hex-encoded. sig_script embeds the journal hash,
the compressed proof, and the redeem script itself — it goes on the
spending input's signature_script verbatim.
The succinct twin is
finalize_with_succinct_proof(receipt, journal);
its journal argument is likewise the 32-byte journal digest.
Two properties worth knowing:
- Finalize consumes the builder. After a successful finalize (or a
drain()),script()returns""and further mutating calls raiseZkError. - A failed finalize does not. If the receipt won't decode or a push exceeds the script limits, the builder survives with its state and committed script intact — fix the input and finalize again.
Spending the locked UTXO¶
The redeem transaction differs from an ordinary P2SH spend in three
ways, all visible in
examples/zk/groth16_onchain.py:
from kaspa import TransactionInput, TransactionOutpoint, Hash
spend = TransactionInput(
TransactionOutpoint(Hash(commit_txid), 0),
finalized.sig_script, # the proof is the authorization
sequence=0,
sig_op_count=0,
compute_budget=1600, # covers the Groth16 precompile
utxo=p2sh_utxo,
)
- No signature. The proof alone satisfies the lock, so the redeem
input carries no Schnorr signature and the transaction is submitted
unsigned. That makes the redeem permissionless: anyone holding a
valid proof for the
(image_id, journal_hash)pair can spend the UTXO. - Compute budget. Proof verification is metered: a Groth16
verification is priced at 140,000 grams (1,400 budget units) and a
succinct one at 250,000 grams (2,500 units), plus the surrounding
script opcodes — so the redeem input needs a
compute_budgetwith headroom (the example uses 1600 for Groth16). Too low and the redeem fails. - Activation.
OpZkPrecompileonly runs where Toccata is active — pre-check the virtual DAA score against the activation score before submitting.
Everything else — sizing the fee from
calculate_transaction_mass,
building the Transaction,
submission — is ordinary transaction plumbing; see
Transactions.
Next: composing scripts by hand from the verifier and proof pieces — Fragments & Helpers.