Introduction
Welcome to The tx-manifest Cookbook — a recipe-driven guide to writing manifests: machine-readable descriptions of multi-UTXO protocols on Bitcoin and Liquid.
This book is not a top-to-bottom reference manual. It is a cookbook in the style of the Rust Cookbook: a sequence of small, self-contained recipes, each solving one concrete problem and introducing one or two new concepts. You can read it straight through, or jump to the recipe that matches what you are trying to do.
Who this is for
- Protocol authors who have a Simplicity or miniscript contract and want a portable description of the transactions that drive it.
- Wallet and tooling developers who want to read a single file and know which UTXOs to watch, which transactions are valid, and which witnesses to construct.
- Anyone trying to understand an unfamiliar on-chain protocol without reverse-engineering it from source.
How the book is organised
- Getting Started explains what a manifest is, gets the
tx-manifest-walletCLI built and a wallet ready, and dissects the top-level structure of a file. - The Cookbook is the heart of the book. Each recipe builds on the previous one, starting from a no-covenant warm-up (splitting a UTXO) and a single key locking a single output, then growing toward covenants, issuance, and multi-step lifecycles.
- The Full Walkthrough ties every concept together on a real peer-to-peer lending protocol.
- The Appendix is the quick-reference material: type tables, the formula language grammar, and the full CLI command list.
How to read a recipe
Every recipe in the Cookbook follows the same shape:
Problem — one sentence describing the goal.
Recipe — the manifest JSON you can copy and adapt.
How it works — an annotated tour of the new fields.
Run it — the actual
tx-manifest-walletcommands to execute the action.Try next — where to go from here.
Every JSON snippet and command in this book is drawn from real files in the
repository (examples/p2pk/txmanifest.json, example/lending/) and the real CLI
in txmanifest_wallet — nothing here is invented.
The authoritative reference is
Spec.mdin the repository root. When this book and the spec disagree, the spec wins. This cookbook aims to teach; the spec aims to be complete.
Let's start with the big picture: what is a manifest?
What is a manifest?
Any multi-UTXO protocol — whether it uses Bitcoin miniscript, Tapscript, or Liquid Simplicity — imposes a specific transaction layout. Covenants that do transaction introspection are especially strict: input 0 must be a specific asset, output 1 must go to a specific script hash, output 2 must carry exactly the right amount. The on-chain program enforces this, but someone still has to document what layout it expects.
Historically that documentation was a PDF, a Notion page, or a comment in the source. It was informal and only useful to the person who wrote it. Anyone else building a wallet integration had to reverse-engineer the expected transaction shapes and hope the docs were current.
A manifest formalises that document. The same information that used to go into prose — "the pre-lock UTXO must be at input index 0, the collateral goes to output 2, the borrower's NFT must be co-spent" — is expressed as structured JSON that tools can read.
The three-file model
A live contract is described by three companion files:
| File | Naming | What it holds | Lifetime |
|---|---|---|---|
| Manifest | txmanifest.json | The protocol definition: classes, actions, inputs, outputs, witnesses. | Static — shared by every deployment. |
| Instance file | <name>.instance.json | The compile-time parameters for one deployment (this borrower's pubkey, this loan's amount). | Created when the contract is instantiated. |
| State file | <name>.state.json | The live on-chain UTXO set for this instance. | Updated after every broadcast. |
The manifest is the cookbook recipe; the instance file is the specific ingredients you bought; the state file is what's currently in the pot.
For the first several recipes we work only with the manifest — the other two are introduced in Instance, state & constructors.
What a manifest contains
A manifest has a small number of top-level sections:
classes— the contract types. Each class has typed fields and methods. A class's fields are the contract's compile-time parameters — the values baked into its covenant scripts (pubkeys, asset IDs, amounts, expiry heights). Changing a field produces a different script, and therefore a different on-chain address. Field values are fixed per deployment and stored in the instance file, not in the manifest.utxo_types— the on-chain states the protocol can create. Each has a known script so a wallet can recognise these outputs on-chain.actions(also called methods) — the valid transactions. Each one says which UTXOs it consumes, which it produces, the amount formulas, and the witnesses needed to satisfy the covenants. Actions live either inside a class'smethodsor, for simple contracts, at the top level.lifecycle— documentation-only: named states, the transitions between them, and whether each action is cooperative or unilateral.
We dissect each of these in Anatomy of a manifest. But first, let's get the tooling ready.
Setup
To run the recipes in this book (not just read them) you need the
tx-manifest-wallet CLI, a wallet, and a connection to a Liquid testnet Esplora
server.
tx-manifest-walletis an example implementation of a wallet. It is a reference tool that consumes manifests and walks through the full build-and-sign lifecycle so the recipes in this book are runnable. It is not the only way to consume a manifest — any wallet can implement the same lifecycle. If you are building your own wallet, see the Wallet implementation guide for the execution lifecycle a wallet follows when executing an action.
Get the CLI
The wallet binary is tx-manifest-wallet. There are four ways to get it; pick
whichever suits you. Option 1 (the codespace) is the quickest way to try the
recipes — nothing to install.
This book aliases the binary to
txwpurely to keep the commands short and readable. Every command below is written astxw <subcommand>— read that astx-manifest-wallet <subcommand>if you prefer the full name. The Blockstream codespace ships thetxwalias already; with the other options, add it yourself:alias txw=tx-manifest-wallet
Option 1 — Blockstream Simplicity codespace (no install)
The Blockstream Simplicity codespace
comes with the example wallet preinstalled and already aliased to txw, alongside
the SimplicityHL toolchain. Open it in GitHub Codespaces and you can run the
recipes immediately — no local setup:
txw --help
Option 2 — download a release binary
If you don't want to compile, grab a prebuilt binary from the
releases page.
Builds are published for Linux (x86_64), macOS (Apple Silicon), and Windows
(x86_64). Download the archive for your platform, unpack it, and put
tx-manifest-wallet on your PATH:
# Example: Linux x86_64, release v0.1.0 (substitute the current version)
curl -LO https://github.com/stringhandler/txmanifest-wallet/releases/download/v0.1.0/tx-manifest-wallet-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
tar xzf tx-manifest-wallet-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
sudo mv tx-manifest-wallet /usr/local/bin/
txw --help
Asset names are version-stamped. The macOS Apple Silicon and Windows builds are
tx-manifest-wallet-<version>-aarch64-apple-darwin.tar.gzandtx-manifest-wallet-<version>-x86_64-pc-windows-msvc.zip. Check the releases page for the exact file name of the latest version.
Option 3 — asdf
The asdf plugin installs prebuilt release binaries (Linux
x86_64 and macOS Apple Silicon; asdf is shell-based, so Windows isn't supported):
asdf plugin add tx-manifest-wallet https://github.com/stringhandler/asdf-tx-manifest-wallet.git
asdf install tx-manifest-wallet latest
asdf set -u tx-manifest-wallet latest
txw --help
Option 4 — build from source
The CLI is the txmanifest_wallet
crate of a standard Cargo workspace. From a clone of the repository:
cargo build --release # binary at ./target/release/tx-manifest-wallet
alias txw="$(pwd)/target/release/tx-manifest-wallet"
txw --help
Throughout the book, commands are written as txw <subcommand>. Manifest paths
like examples/p2pk/txmanifest.json are relative to your current directory — run
from a clone of the repository (or the codespace) to use the bundled examples.
Configure the network and backend
The CLI keeps a small config file with two keys: the default network and the default Esplora URL. Set them once:
txw config default_network testnet
txw config default_esplora https://blockstream.info/liquidtestnet/api
Run config with no arguments to print the current values:
txw config
Most subcommands also accept --network and --esplora flags to override the
defaults per-invocation.
Create a wallet
txw create-wallet --out wallet.json
This writes a new HD wallet to wallet.json. Add --mainnet true for a mainnet
wallet; by default it follows your configured default_network.
Inspect it — fingerprint, master xpub, oracle key, and a receive address:
txw info --wallet wallet.json
The wallet derives keys on the BIP86 (taproot) paths the spec expects:
| Path (testnet) | Path (mainnet) | Role |
|---|---|---|
m/86h/1h/0h/0/0 | m/86h/0h/0h/0/0 | Wallet signing key |
m/86h/1h/1h/0/0 | m/86h/0h/1h/0/0 | Oracle key |
A compile_params entry with source: { "type": "wallet_key" } is auto-filled
from the first path; oracle_key from the second. (More on this in
Parameters & validations.)
Fund and sync
Your new wallet is empty. Fund it with Liquid testnet L-BTC from the faucet:
-
Get your receive address. Run
infoand copy the receive address it prints:txw info --wallet wallet.jsonAmong the output (fingerprint, xpub, oracle key) is a receive address — copy that value.
-
Request coins from the faucet. Open the Liquid testnet faucet, paste your receive address into the address field, and request the funds. The faucet broadcasts a small amount of testnet L-BTC to your wallet.
-
Sync the wallet once the faucet transaction has been broadcast, so the CLI picks up the new UTXO from Esplora:
txw sync --wallet wallet.json
sync scans the chain, updates the persisted wallet state, and prints your
balance. To re-print the last known balance without hitting the network:
txw get-balance --wallet wallet.json
Prepare UTXOs for an action
Many actions need several separate UTXOs (one per input). The prepare
subcommand inspects an action and, if the wallet doesn't have enough discrete
UTXOs, builds and broadcasts a split transaction to create them:
txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json
You can also split manually:
txw split -n 4 --asset lbtc --amount-each 10000 --wallet wallet.json
With a funded, synced wallet you're ready for the first recipe: Hello World: Pay-to-Public-Key.
Anatomy of a manifest
Before writing any recipes, let's look at the skeleton every manifest shares. A manifest is a single JSON document. At the top level it has an envelope of metadata fields followed by the data sections.
{
"manifest_version": "0.1.0",
"attestation_version": "1",
"protocol": "p2pk-simplicity",
"description": "Pay-to-public-key using a Simplicity checksig program on Liquid.",
"chain": "liquid",
"compile_params": { ... },
"utxo_types": { ... },
"classes": { ... },
"actions": { ... },
"lifecycle": { ... }
}
The envelope
| Field | Required | Purpose |
|---|---|---|
manifest_version | yes | Version of the tx-manifest format itself. Current: "0.1.0". |
protocol | yes | Kebab-case protocol identifier, e.g. "simplicity-lending". |
description | yes | Free-text summary of the whole protocol. |
chain | no | "bitcoin", "liquid"/"elements", or "cross-chain". Defaults to "elements". |
attestation_version | no | Schema version for any signatures added to the document. |
simplicity_hl_version | no | SimplicityHL compiler version the scripts require. |
source | no | Relative path to the top-level .simf file. |
confidential_outputs | no | File-level default for output blinding. See Outputs & destinations. |
The data sections
A file carries up to five data sections. Two of them — the contract's compile-time fields and its methods — can be written one of two ways:
- Grouped into a top-level
classesmap (the canonical model, used by the lending example). Each entry is a deployable contract type bundling itsfieldsandmethods. - Flattened into a top-level
compile_paramsblock plus a top-levelactionsmap. Simpler, and used by the early recipes in this book.
The other sections — utxo_types and lifecycle — look the same either way.
Most files won't carry every section.
classes — typed contracts (fields + methods)
A class is a typed contract definition: one deployable contract type with its
compile-time fields and the methods (actions) that operate on it, all grouped
under a top-level classes map. Richer protocols use this form — the canonical
lending file is built entirely from classes.
"classes": {
"p2pk_contract": {
"description": "Pay-to-public-key contract.",
"fields": {
"PUBKEY": { "type": "pubkey", "description": "Key that controls spending." }
},
"methods": { "Pay": { ... }, "Receive": { ... } }
}
}
A file may also carry a top-level actions map alongside classes, for
utility actions that don't belong to a single instance (the lending file uses
this for its Prepare actions). Classes, fields, and methods are covered in full
in Instance, state & constructors.
Compile-time parameters — what's baked in at deploy time
Whether they live in a class's fields or a top-level compile_params block,
compile-time values parameterize the covenant scripts: a pubkey, an asset ID, a
loan amount, an expiry height. They are fixed for a deployment — change one and
you get a different script hash, and therefore a different address.
In the canonical model they are the fields of a class (above), and their
values are stored in the instance file. Simpler single-type contracts —
including the early recipes in this book — may instead declare them in a
top-level compile_params block. Both forms are accepted by the tooling:
"compile_params": {
"user_provided": {
"PUBKEY": { "type": "pubkey", "description": "Key that controls spending." }
}
}
Some params are derived rather than supplied — computed from other params or from the outpoints of issuance inputs. See Formulas & derived params.
utxo_types — the on-chain states
Each UTXO type is a named on-chain state with a known script (usually a Taproot address built from a Simplicity leaf). A wallet uses these definitions to recognise the protocol's outputs on-chain.
"utxo_types": {
"p2pk_output": {
"description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.",
"script": {
"type": "simplicity",
"source": "./p2pk.simf",
"compile_params": { "PUB_KEY": "PUBKEY" }
},
"asset": "lbtc"
}
}
We cover the script block in detail in
Covenant UTXO types.
actions — the valid transactions
Each action (or method) is a single transaction recipe: which UTXOs to consume
(inputs), what to create (outputs), what witnesses to provide (witnesses),
and what must be true before building (validations).
"actions": {
"Pay": {
"description": "Pay a recipient by locking funds into a p2pk output keyed to their public key.",
"params": { ... },
"inputs": [ ... ],
"outputs": [ ... ],
"validations": [ ... ]
}
}
Classes vs. top-level actions. In richer protocols, actions are grouped inside a
classes.<id>.methodsblock — a class is a typed contract with fields and methods. For simple, single-type contracts, actions can live directly under top-levelactions. Structurally a method and an action are identical. We start with top-levelactionsand introduce classes in Instance, state & constructors.
lifecycle — documentation of the state machine
Purely descriptive: the named states, the transitions between them, and whether
each action needs one party (unilateral) or both (cooperative). Tools render
diagrams from it, but nothing on-chain depends on it.
"lifecycle": {
"states": ["paid", "received"],
"transitions": {
"Pay": { "to": "paid" },
"Receive": { "from": "paid", "to": "received" }
}
}
The execution model, in brief
When you run an action, a tool like tx-manifest-wallet performs roughly these steps:
- Resolve parameters — load compile params, auto-derive wallet keys, apply overrides.
- Resolve inputs — find each input UTXO (from state file, wallet, or
provided_inputs). - Compute derived params — compile
.simffiles to get covenant script hashes. - Run validations — abort if any rule is false.
- Construct outputs — evaluate amount and asset formulas, resolve destinations.
- Build the PSET, sign (computing Simplicity witnesses), and broadcast.
- Update the state file — remove spent UTXOs, add new covenant outputs.
You don't need to memorise this yet — each recipe touches the parts it needs. The
full sequence is in Spec.md §11.
With the skeleton in hand, let's write our first contract.
Splitting a UTXO
Problem. Turn one large wallet UTXO into several smaller ones — a handy warm-up, and a common prerequisite for actions that need several discrete input UTXOs.
Before the first real contract, here is the gentlest possible manifest: no covenants, no witnesses, no compile parameters. Just one input and a handful of outputs, all to your own wallet. It does one useful thing — split a UTXO into four equal pieces — and in doing so introduces the bare skeleton every manifest shares.
The full manifest is reproduced inline below — save it as txmanifest.json.
Recipe
{
"manifest_version": "0.1.0",
"attestation_version": "1",
"protocol": "utxo-split",
"description": "Split one wallet UTXO into four equal wallet UTXOs.",
"chain": "liquid",
"actions": {
"Split": {
"description": "Split a wallet UTXO into four outputs of amount_each, returning any remainder (less fees) as change.",
"params": {
"amount_each": {
"type": "u64",
"description": "Satoshis to place in each of the four output UTXOs."
}
},
"inputs": [
{
"id": "funding_input",
"description": "A wallet UTXO large enough to cover four outputs plus fees.",
"utxo_source": "wallet",
"asset": "lbtc",
"amount_sat": { "min_amount": "params.amount_each * 4" }
}
],
"outputs": [
{ "id": "split_0", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
{ "id": "split_1", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
{ "id": "split_2", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
{ "id": "split_3", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
{ "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }
],
"validations": [
{
"id": "amount_nonzero",
"rule": { "type": "arithmetic", "expr": "params.amount_each > 0" },
"error": { "code": "INVALID_AMOUNT", "message": "amount_each must be greater than zero" }
}
]
}
}
}
How it works
The whole envelope, and nothing else. This file has the required top-level
fields (manifest_version, protocol, description, chain) and a single
actions block. There are no utxo_types (no on-chain covenant states), no
compile_params (nothing is baked into a script), and no classes. A manifest
can be this small.
One action parameter. amount_each is an action param of type u64 — you
supply it each time you run Split. It is not a compile param: it doesn't change
any script or address, it only affects the amounts in this one transaction.
One wallet input. funding_input draws from utxo_source: "wallet" — any
wallet-controlled UTXO. Its amount_sat uses the { "min_amount": ... } form
with a formula, params.amount_each * 4, so the tool auto-selects a UTXO worth at
least four shares. (Operators like * are covered in
Formulas & derived params.)
Four equal outputs, plus change. Each split_n output sends amount_each to
destination: "wallet", your own receive address. The final
change_out has no amount_sat — a change destination automatically receives
whatever is left after the four outputs and the fee, and it's optional so the
action still works if that remainder is zero.
No witnesses. Every input here is an ordinary wallet UTXO, which the wallet library signs with a standard Schnorr signature. Witnesses only appear when you spend a covenant — which is exactly what the next recipe introduces.
Run it
Make sure you have a funded, synced wallet (Setup). From the repository root:
# Optional: check the manifest's schema before running anything.
txw validate txmanifest.json
txw run txmanifest.json Split \
--network testnet --wallet wallet.json
You'll be prompted for amount_each; the tool selects an input, builds the four
outputs plus change, signs, and broadcasts. Afterwards your wallet holds four
fresh UTXOs.
The built-in shortcut. Because splitting is so common, the CLI ships it as a first-class command — no manifest needed:
txw split -n 4 --asset lbtc --amount-each 10000 --wallet wallet.jsonAnd
preparewill split automatically when an action needs more UTXOs than the wallet currently has:txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json
Try next
That's the skeleton. The next recipe adds the first real Simplicity covenant — a UTXO type, a script, and the witnesses to spend it: Hello World: Pay-to-Public-Key.
Hello World: Pay-to-Public-Key
Problem. Lock a Liquid output so that only the holder of one private key can spend it — and meet Simplicity, the on-chain language that enforces the lock.
After the no-covenant warm-up in Splitting a UTXO, this
is the first contract with a real covenant: an on-chain program that decides
whether a UTXO may be spent. It is the manifest equivalent of
println!("Hello, world!").
This lesson covers only the Pay action — locking funds into the covenant.
Spending those funds back out (the Receive action) needs a signature witness
and gets its own lesson later. The full file is
txmanifest.json
under examples/p2pk/.
Introducing Simplicity
Until now our outputs went to ordinary wallet addresses. A covenant output is
different: its address is a program. On Liquid that program is written in
SimplicityHL — a high-level language that compiles to Simplicity — and lives
in a .simf file. The compiler turns it into a 32-byte commitment (its CMR),
which becomes the output's Taproot address. To spend the output you must supply a
witness that makes the program succeed.
A manifest does not contain the program; it points at the .simf file and
supplies its compile-time parameters. So a real contract is now two files that
live side by side:
your-book-folder/
├── txmanifest.json ← the manifest (references "./p2pk.simf")
└── p2pk.simf ← the Simplicity program, compiled into the address
The source path in the manifest is resolved relative to the manifest's
own directory, so keep the .simf next to it.
The program: p2pk.simf
Create p2pk.simf with exactly this content:
fn main() { let sig: Signature = witness::SIGNATURE; jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig); }
Four pieces:
witness::SIGNATURE— a value supplied by the spender at spend time. The program reads it intosig. (We don't supply it in this lesson becausePayonly creates the output.)param::PUB_KEY— a compile-time parameter baked into the program. Different keys compile to different programs, and therefore different addresses. We supply its value from the action below.jet::sig_all_hash()— a jet (a built-in Simplicity primitive) that returns the signature hash committing to the whole transaction.jet::bip_0340_verify((PUB_KEY, message), sig)— verifies thatsigis a valid BIP340 Schnorr signature over that message byPUB_KEY. If it isn't, the program fails and the spend is rejected.
In plain English: "this output may be spent only by a signature from PUB_KEY
over this transaction." That is pay-to-public-key.
The manifest
Start with a skeleton
Create txmanifest.json next to p2pk.simf, with every top-level section
present but empty:
{
"manifest_version": "0.1.0",
"attestation_version": "1",
"protocol": "p2pk-simplicity",
"description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.",
"chain": "liquid",
"utxo_types": {},
"actions": {}
}
That is the whole shape: the envelope (the first five fields, covered in
Anatomy of a manifest) followed by two empty
data sections we'll fill in below — the UTXO type and the Pay action. There's
no compile_params block: the recipient's key is a runtime parameter of the
action, not a value baked in at deploy time. (And no lifecycle, since this
lesson has a single action.)
We'll fill the two sections in order.
Fill in the UTXO type
The UTXO type names the on-chain state and points at the .simf program. Replace
the empty utxo_types with:
"utxo_types": {
"p2pk_output": {
"description": "A Liquid UTXO locked to a pubkey via the compiled p2pk.simf program.",
"script": {
"type": "simplicity",
"source": "./p2pk.simf"
},
"asset": "lbtc",
"confidential": false
}
},
Notice the script block only names the program — it carries no compile_params
map. The program still has a PUB_KEY parameter to fill, but we'll supply that
per-output, from a value the action receives at run time. That's the next section.
Fill in the Pay action
Finally, the action itself — the value it takes, the UTXOs it consumes, what it
creates, and what must hold before it builds. The Pay action declares a pubkey
parameter and feeds it into the covenant on the output's destination. Replace
the empty actions with:
"actions": {
"Pay": {
"description": "Lock funds into a p2pk output that only the pubkey's owner can spend.",
"params": {
"pubkey": {
"type": "pubkey",
"description": "The x-only public key that will be able to spend this output (the recipient)."
},
"amount_sat": {
"type": "u64",
"description": "Amount in satoshis to lock in the output."
}
},
"inputs": [
{
"id": "funding_input",
"description": "Wallet UTXO providing the funds.",
"utxo_source": "wallet",
"asset": "lbtc",
"amount_sat": { "min_amount": "params.amount_sat" }
}
],
"outputs": [
{
"id": "p2pk_out",
"description": "The funded p2pk output, locked to the recipient's pubkey.",
"destination": {
"utxo_type": "p2pk_output",
"compile_params": { "PUB_KEY": "params.pubkey" }
},
"amount_sat": "params.amount_sat",
"asset": "lbtc"
},
{
"id": "change_out",
"description": "Change returned to the funding wallet.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
],
"validations": [
{
"id": "amount_nonzero",
"rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" },
"error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" }
}
]
}
}
The key line is the output's destination: alongside utxo_type it carries a
compile_params map, { "PUB_KEY": "params.pubkey" }, wiring the runtime pubkey
into the program's param::PUB_KEY just for this output.
With both sections filled in you have the complete file — identical to
txmanifest.json
under examples/p2pk/.
How it works
The recipient's key is a runtime parameter. pubkey lives under the Pay
action's params — you supply it when you run the action (e.g. from the
recipient's info output). It has type pubkey (a 32-byte x-only BIP340 key).
Nothing about it is baked into the file at deploy time; there's no
compile_params block at all.
The output wires that key into the covenant. The p2pk_out destination is
{ "utxo_type": "p2pk_output", "compile_params": { "PUB_KEY": "params.pubkey" } }.
That compile_params map feeds the runtime pubkey into the program's
param::PUB_KEY. The tool compiles p2pk.simf with that key and derives the
covenant's Taproot address — using a NUMS internal key so the only way to spend is
through the script. Because the key is baked into the compiled program, two
different keys give two different p2pk_output addresses. (We unpack that
derivation in Covenant UTXO types.)
Pay creates the covenant output. The p2pk_out output sends funds to the
p2pk_output covenant; the tool computes that covenant's address (from
params.pubkey, above) and locks the funds there. The funding_input is a plain
"wallet" UTXO, auto-selected via { "min_amount": ... } to cover the amount, and
the remainder returns as change.
No witnesses yet. Pay only builds the locked output — it doesn't spend a
covenant — so there's nothing to satisfy and no witness to provide. The
witness::SIGNATURE in p2pk.simf only matters when you spend the output, which
is the next lesson.
Run it
Make sure you have a funded, synced wallet (Setup),
and that p2pk.simf sits next to the manifest. From the repository root:
# Optional: check the schema first.
txw validate examples/p2pk/txmanifest.json
# Make sure the wallet has a UTXO big enough for Pay.
txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json
# Lock funds into a p2pk output. You'll be prompted for pubkey and amount_sat.
txw run examples/p2pk/txmanifest.json Pay \
--network testnet --wallet wallet.json
run prompts for pubkey and amount_sat, compiles p2pk.simf to derive
the covenant address, builds the PSET, signs the wallet input, and broadcasts. The
new p2pk_output is recorded in the state file, ready to be spent in a later
lesson.
Tip. Add
--export-pset out.jsonto write the signed PSET to a file instead of broadcasting, or--debug-jetsto print every Simplicity jet call.
Heads up for Part 2. If you plan to follow Part 2 and spend this output, lock it to your own wallet key — use the pubkey from
infoforpubkey. Spending requires signing with that key's private half, so paying to someone else's key means only they can reclaim it.
The state file
After a successful Pay, the tool records the new covenant output in a state
file next to your manifest, auto-named txmanifest.state.json:
{
"last_action": "Pay",
"utxos": [
{
"utxo_type": "p2pk_output",
"utxo_id": "p2pk_out",
"txid": "271c1afc4e6137b77874be8d9451a84e122b4bda445d512a45e61eaaddbdaab5",
"vout": 0,
"amount_sat": 1000,
"asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"
}
]
}
This is the protocol's live on-chain state: one entry per covenant UTXO the
contract currently owns. The p2pk_out output you just created is now tracked as
a p2pk_output, keyed by its txid/vout and ready to be consumed as an input
by a later action (the Receive spend). Each utxo_id matches the id of the
output that produced it. When a UTXO is later spent, the tool removes it here;
when an action creates new covenant outputs, it adds them.
No instance file
A contract has up to two companion files: an instance file (compile-time
field values) and a state file (live UTXOs). This lesson produced only the
state file — there is no txmanifest.instance.json.
Why? Instance files exist to persist a class's fields. This contract declares
no classes at all — and the recipient's key (the pubkey action parameter) is
supplied fresh at run time, never stored. With no class fields to record, there is
nothing for an instance file to hold. Instance files first appear once we
introduce classes and constructors in
Instance, state & constructors.
Try next
You now have a covenant on-chain. The next recipe digs deeper into parameters —
the pubkey and amount_sat values you just supplied — and adds runtime
validation: Parameters & validations.
Hello World, Part 2: Spending the output
Problem. Take the covenant output you created in Part 1 and spend it back into your wallet — by producing a signature that satisfies the on-chain program.
Part 1's Pay action only built a covenant output; it locked funds into a
p2pk_output and recorded that UTXO in the state file. This lesson adds the
Receive action, which spends it. Three new things have to come together:
- The state file locates the UTXO. We never type a txid — the tool reads
txmanifest.state.jsonand finds the livep2pk_outputentry. - The same key rebuilds the same address. The output is locked at an address
derived from the recipient's pubkey. To spend it, the tool must recompile
p2pk.simfwith that same key and confirm the address matches. - A witness satisfies the program.
p2pk.simfdemands a BIP340 signature over the transaction.Receiveprovides one.
Prerequisites. You must have run
Payfirst, sotxmanifest.state.jsonholds ap2pk_output. Crucially, in Part 1 you must have locked the funds to one of your own wallet's keys (e.g. the key frominfo) — because spending now requires signing with that key's private half. If you paid to someone else's pubkey, only they can runReceive.
The Receive action
Add this action alongside Pay in txmanifest.json:
"Receive": {
"description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.",
"params": {
"pubkey": {
"type": "pubkey",
"description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend."
}
},
"inputs": [
{
"id": "p2pk_in",
"description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.",
"utxo_source": {
"utxo_type": "p2pk_output",
"compile_params": { "PUB_KEY": "params.pubkey" }
},
"witnesses": {
"SIGNATURE": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "params.pubkey" },
"description": "BIP340 Schnorr signature over the whole transaction, from the recipient key."
}
}
},
{
"id": "fee_input",
"description": "Wallet L-BTC UTXO to pay the network fee.",
"utxo_source": "wallet",
"asset": "lbtc",
"optional": true
}
],
"outputs": [
{
"id": "received_out",
"description": "The reclaimed funds, sent to your wallet.",
"destination": "wallet",
"asset": "lbtc",
"amount_sat": "p2pk_in.amount_sat"
},
{
"id": "fee_change",
"description": "L-BTC change from the fee input.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
]
}
How it works
The input comes from the state file, not your wallet. p2pk_in's
utxo_source is { "utxo_type": "p2pk_output" }. Unlike a "wallet" input,
this tells the tool to look in txmanifest.state.json for a live UTXO of that type —
the very one Pay recorded. That's why this lesson "requires the state file":
without it the tool has no idea the UTXO exists.
compile_params rebuilds the covenant address. A covenant UTXO has no key in
the usual sense — its address is the compiled program. To spend it, the tool
recompiles p2pk.simf and checks the resulting Taproot address against the one
the funds are sitting at. That compile needs PUB_KEY, so the input carries the
same per-site map you saw on the Pay output:
{ "PUB_KEY": "params.pubkey" }. Supply the identical pubkey you used in
Pay — a different key compiles to a different address, and the UTXO simply
won't match.
The SIGNATURE witness satisfies the program. Recall p2pk.simf:
#![allow(unused)] fn main() { let sig: Signature = witness::SIGNATURE; jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig); }
The program reads witness::SIGNATURE and verifies it against PUB_KEY. The
input's witnesses map provides exactly that name:
type: "Signature"— the tool computes the signature itself rather than taking a literal value.sig_type: "sig_hash_all"— the message to sign is Simplicity'ssig_all_hash, a commitment over the whole transaction. (This is not the classic Bitcoin/ElementsSIGHASH_ALL; it's Simplicity's own hash. See Witnesses.)source: { "type": "wallet", "key": "params.pubkey" }— the tool searches your wallet's BIP86 derivation paths for the private key matching that pubkey, signs the hash, and injects the 64-byte signature as theSIGNATUREwitness.
Because the program checks the signature against the same PUB_KEY baked into
the address, only the holder of that key can produce a spend that succeeds.
Why the separate fee_input. received_out returns the full
p2pk_in.amount_sat to your wallet, so there's nothing left over for the network
fee. The optional fee_input pulls a small L-BTC UTXO from your wallet; the fee
is taken from its fee_change. (If you'd rather, drop the fee input and lower
received_out by the fee instead — but a separate fee input keeps the covenant
amount clean.)
No path selector needed. p2pk.simf is a single-leaf covenant with one
witness, so there's nothing to choose — SIGNATURE is the only witness. Richer
covenants with multiple spending paths add a selector witness; that's
Multiple spending paths.
Run it
With a funded, synced wallet and a p2pk_output already in the state file from
Part 1, run:
txw run examples/p2pk/txmanifest.json Receive \
--network testnet --wallet wallet.json
run prompts for pubkey (use the same key as in Pay), finds the
p2pk_output in the state file, rebuilds the covenant address to confirm the
match, builds the PSET, and computes the signature. Before broadcasting it runs a
Simplicity dry-run — actually executing the covenant program against the
spending transaction to prove the witness satisfies it — then signs, broadcasts,
and updates the state file.
Tip. Add
--debug-jetsto watchbip_0340_verifyandsig_all_hashexecute during the dry-run, or--export-pset out.jsonto inspect the spend without broadcasting.
The state file after spending
A successful Receive consumes the covenant UTXO, so the tool removes it from
txmanifest.state.json. If that was the only entry, utxos is now empty:
{
"last_action": "Receive",
"utxos": []
}
The funds are back in your wallet as an ordinary output. The round trip is
complete: Pay moved L-BTC into the covenant and added a state entry; Receive
spent it and removed that entry.
Try next
You've now built and spent a covenant — the full lifecycle of the simplest contract. The next recipe looks more closely at the parameters and validation rules that drive these actions: Parameters & validations.
Parameters & validations
Problem. Reject obviously-broken transactions before building them, and understand when a value belongs in
compile_paramsversus an action'sparams.
The Pay action from recipe 1 would happily let you
pay an output of zero satoshis. This recipe adds a validation rule to stop that,
and along the way pins down the two kinds of parameters a manifest deals with.
Two kinds of parameters
This trips up everyone at first, so it's worth being precise:
compile_params | action params | |
|---|---|---|
| When fixed | At deploy time, once. | Per transaction, every time you run the action. |
| Baked into the script? | Yes — they change the covenant's address. | No — they only affect this transaction. |
| Stored in | the instance file | nowhere; supplied at run time |
| Example | PUBKEY, LOAN_EXPIRATION_TIME | amount_sat, CURRENT_BLOCK_HEIGHT |
A useful test: "if I changed this value, would the on-chain address change?" If yes, it's a compile param. If it only affects which inputs/outputs this particular transaction picks, it's an action param.
Auto-filled params with source
An action param (or a user-provided compile param) can declare a source so the
tool fills it in without prompting:
"params": {
"BORROWER_PUB_KEY": {
"type": "pubkey",
"description": "Borrower's signing key.",
"source": { "type": "wallet_key" }
}
}
source.type | Resolves to | Derivation path (testnet / mainnet) |
|---|---|---|
"wallet_key" | 32-byte x-only pubkey | m/86h/1h/0h/0/0 / m/86h/0h/0h/0/0 |
"oracle_key" | 32-byte x-only pubkey | m/86h/1h/1h/0/0 / m/86h/0h/1h/0/0 |
If a param has no source, the tool prompts you for it interactively (or you
supply it via --params, below). In recipe 1, PUBKEY has no source, so Pay
prompts you for it. The lending protocol's BORROWER_PUB_KEY (above) uses
wallet_key, so it's filled from the wallet silently.
Recipe
Add a validations array to the Pay action. Each rule is checked before the
PSET is built; if any rule's expression is false, the action aborts with the
rule's error message.
"Pay": {
"description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.",
"params": {
"amount_sat": { "type": "u64", "description": "Amount in satoshis to lock." }
},
"inputs": [ ... ],
"outputs": [ ... ],
"validations": [
{
"id": "amount_nonzero",
"description": "Must lock a positive amount.",
"rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" },
"error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" }
}
]
}
The other rule type, utxo_exists, guards an action that spends a covenant —
you'll add one when you build the spend action in a later lesson. It checks that a
UTXO of a given type exists before the action runs:
"validations": [
{
"id": "p2pk_exists",
"description": "A p2pk output must exist before it can be spent.",
"rule": { "type": "utxo_exists", "utxo_type": "p2pk_output" },
"error": { "code": "MISSING_UTXO", "message": "No p2pk UTXO found. Has Pay been run?" }
}
]
How it works
A validation rule has four parts:
| Field | Required | Purpose |
|---|---|---|
id | yes | Unique name for the rule (shown in errors and logs). |
description | no | Human-readable intent. |
rule | yes | The check itself — see the two types below. |
error | no | { "code", "message" } surfaced when the rule fails. |
There are two rule types:
arithmetic— theexpris a formula that must evaluate totrue. Use it for amount bounds, timelock checks, relationships between params.params.amount_sat > 0is the simplest case;compile_params.LOAN_EXPIRATION_TIME < params.CURRENT_BLOCK_HEIGHTis a real one from the lending protocol.utxo_exists— names autxo_typethat must have at least one live entry in the state file. Use it as a precondition: don't try to spend something that was never created.
When validations run. All params are resolved and all inputs are selected before validations execute, so a rule can reference resolved input amounts and assets — but it runs before the PSET is constructed, so a failing rule costs nothing. Any failure aborts the whole action.
Error codes. The
error.codestrings can be collected into a top-levelerrorsmap ({ "1": "Loan has not yet expired", ... }) that documents every failure mode the protocol can produce. This is optional but recommended for protocols a wallet will surface to users.
Run it
Validations are invisible on the happy path. To see one fire, run Pay and
enter 0 when prompted for amount_sat:
txw run examples/p2pk/txmanifest.json Pay \
--network testnet --wallet wallet.json
# → aborts with: INVALID_AMOUNT: Amount must be greater than zero
Supplying params non-interactively
Instead of typing params at the prompt, pass a flat JSON file of string→string
values and reference it with --params:
{ "amount_sat": "50000", "PUBKEY": "<64-hex-char x-only pubkey>" }
txw run examples/p2pk/txmanifest.json Pay \
--network testnet --wallet wallet.json --params pay-params.json
The CLI also auto-discovers a per-network param file when you pass --network
(see the lending example's *.testnet.json files). An explicit --params file
always takes precedence.
Try next
Validations guard the inputs. Next we get precise about the outputs: the different destinations a value can go to, and how confidentiality is decided: Outputs & destinations.
Outputs & destinations
Problem. Send a transaction's value to the right place — a wallet, a change address, a covenant, a raw script hash, or an
OP_RETURN— and control whether each output is blinded.
Every action so far produced two outputs: a covenant output and a change output. Those are only two of the destinations available. This recipe is a tour of all of them, plus the rules for output confidentiality.
The output descriptor
An output descriptor has these fields:
| Field | Required | Purpose |
|---|---|---|
id | yes | Unique name within the action. |
destination | yes | Where the value goes. See below. |
amount_sat | usually | Amount in satoshis — a literal or a formula. Omit it for a change destination, which auto-computes the remainder. |
asset | yes | Asset ID — "lbtc", a 64-char hex ID, or a param reference. |
description | no | Human-readable purpose. |
required_index | no | Force this output to a specific transaction index. |
optional | no | If true, the output may be omitted (e.g. zero change). Default false. |
confidential | no | Whether to blind this output. See the rules below. |
data | no | OP_RETURN payload — only valid with the op_return destination. |
Recipe: every destination type
Wallet and change
{ "id": "to_me", "destination": "wallet", "amount_sat": "...", "asset": "lbtc" }
{ "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }
wallet is your primary receive address; change is your change address. A
change output needs no amount_sat — the tool sends whatever is left after the
other outputs and fees. It is almost always optional too, since that remainder
can be zero.
An address supplied at run time
{ "id": "recipient_output", "destination": "params.recipient_address", "amount_sat": "params.send_amount_sat", "asset": "lbtc" }
The destination is a bare string referencing an action param of type address —
the way to pay an arbitrary recipient address that the user supplies at run time.
A covenant UTXO type
{ "id": "p2pk_out", "destination": { "utxo_type": "p2pk_output" }, "amount_sat": "params.amount_sat", "asset": "lbtc" }
The tool computes the named UTXO type's P2TR address from its .simf source and
compile params, and locks the output there. This is how a transaction creates the
protocol's on-chain states — exactly what Pay in
recipe 1 does with p2pk_output.
A raw script hash from a compile param
{ "id": "relocked", "destination": { "script_hash": "compile_params.PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" }, "amount_sat": 1, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID" }
When you already hold a 32-byte covenant script hash as a derived compile param, embed it directly as a P2TR output without recompiling. The lending protocol uses this to re-lock NFTs under a script-auth covenant.
OP_RETURN
{
"id": "indexer_op_return",
"destination": { "type": "op_return" },
"amount_sat": 0,
"asset": "lbtc",
"data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)"
}
An OP_RETURN output is provably unspendable — its value is destroyed. Two common
uses:
- On-chain discovery. Publish protocol metadata (here, the borrower's pubkey
and principal asset, 64 bytes) so an indexer can list the contract without an
off-chain database. The
datafield is aconcat(...)formula. - Burning a token. Spend an NFT into
OP_RETURNto destroy it — the lending protocol burns auth NFTs this way to prevent reuse.
How confidentiality is decided
On Liquid, outputs can be blinded (amount and asset hidden). The tool resolves blinding in this precedence order:
- The per-output
confidentialfield, if present. - The top-level
confidential_outputsfield, if present. - The chain default:
falsefor Bitcoin,truefor Liquid/Elements.
Covenants and
OP_RETURNare always unblinded, regardless of the settings above. Simplicity covenants introspect explicit amounts and asset IDs with jets likecurrent_amountandcurrent_asset; they cannot read confidential commitments. This is a hard constraint, not a preference — a blinded covenant output would be unspendable.
Forcing output order with required_index
Covenants that introspect the transaction often require outputs in an exact order
("collateral at output 0, principal at output 1"). Pin an output's position with
required_index:
{ "id": "lending_collateral_out", "required_index": 0, "destination": { "utxo_type": "lending_collateral" }, ... }
{ "id": "principal_to_borrower", "required_index": 1, "destination": "params.borrower_address", ... }
Positive indices are absolute (0-based). Negative indices count from the end
(-1 is the last output). The same field exists on inputs — see
Multiple spending paths, where covenant input
ordering matters too.
Run it
Outputs are exercised by every action; there is no standalone command. To inspect exactly what an action will produce without broadcasting, export the PSET and decode it:
txw run examples/p2pk/txmanifest.json Pay \
--network testnet --wallet wallet.json --export-pset pay.pset.json
The exported file lists every output with its amount, asset, and scriptPubKey, so you can confirm the destinations resolved as you intended.
Try next
We've now described value flowing out. Spending a covenant requires witnesses to satisfy its Simplicity program — signatures, path selectors, and computed values. That's the next recipe: Witnesses.
Witnesses
Problem. Provide the values a Simplicity covenant needs to authorise a spend — signatures and branch selectors — and understand what the tool does with them.
You met your first witness in
Hello World, Part 2: the SIGNATURE that
satisfied p2pk.simf. This recipe steps back and covers the witnesses map in
full — what it is, the two kinds the tool produces, and the ones you don't have
to supply.
A witness only matters when you spend a covenant. Creating a covenant output
(Pay) commits to a program; nothing is checked. Spending it (Receive) runs the
program, and the program reads its witnesses to decide whether to allow the spend.
Where witnesses live
Witnesses sit on an input — specifically a covenant input (utxo_source is a
utxo_type, not "wallet"). Plain wallet inputs sign themselves the ordinary
way and have no witnesses map.
{
"id": "p2pk_in",
"utxo_source": { "utxo_type": "p2pk_output", "compile_params": { "PUB_KEY": "params.pubkey" } },
"witnesses": {
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "params.pubkey" } }
}
}
Each key is a SimplicityHL witness name — it must match a witness::NAME in
the .simf source. Our program reads witness::SIGNATURE, so the map has a
SIGNATURE entry. A program with witness::PATH and witness::SIGNATURE would
have entries for both.
The two kinds of witness
The reference tool produces exactly two witness types. (The spec sketches more;
see Not yet wired up below.)
Signature — a computed BIP340 signature
This is the one from Part 2. You don't write a signature by hand; the tool computes it while signing.
"SIGNATURE": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "params.pubkey" }
}
sig_type: "sig_hash_all"selects the message to sign: Simplicity'ssig_all_hash, a commitment over the whole transaction. This is not the classic Bitcoin/ElementsSIGHASH_ALL— it's Simplicity's own hash, computed via the transaction environment (CTxEnv::sighash_all()). It's currently the onlysig_typedefined.source: { "type": "wallet", "key": ... }identifies the signing key. Thekeyresolves to an x-only pubkey — from an actionparam(params.pubkey, as here), a compile param / class field (compile_params.BORROWER_PUB_KEY, the form the lending example uses), or a literal hex value. The tool searches your wallet's BIP86 derivation paths for the private key matching that pubkey and signs with it.
Under the hood the tool computes the hash, signs it, and rewrites the entry as
a simplicityhl witness holding the 64-byte signature as 0x… hex — so a
Signature is really sugar over the next kind.
simplicityhl — a literal typed value
A fixed value, parsed against the witness's type. Use it for branch selectors, indices, and raw byte values.
"PATH": {
"type": "simplicityhl",
"value": "Left(())",
"simplicity_type": "Either<(), ()>",
"description": "Take the first spending path."
}
valueis a SimplicityHL value expression:Left(())/Right(())to choose a branch of anEither,0x<hex>for a byte array,42for an integer.simplicity_typeis optional and documentary. The tool takes the real type from the compiled program's ABI, not from this field — it's there to help a human reader. Provide it for clarity; leave it off and nothing breaks.
Branch selectors are the most common use. A covenant with two spending paths
typically reads a witness::PATH of type Either<(), ()>; supplying Left(())
or Right(()) picks which path runs. That's the subject of
Multiple spending paths.
The witnesses you don't supply
A program declares every witness it could read, but a single spend only travels one path. You supply witnesses for the path you're taking; any witness you omit is filled with a zero value automatically.
That's not a fallback for forgetfulness — it's by design. Before Simplicity prunes
the unused branches, every witness node needs some concrete value. Witnesses on
branches you didn't take (e.g. the SIGNATURE on a cancel path when you chose
PATH = Left) are zeroed, then pruned away and never executed. So:
Supply only the witnesses on the path you're spending. The rest take care of themselves.
This is why our single-path p2pk.simf needs only SIGNATURE, and why a
two-path covenant needs PATH plus the witnesses for the chosen branch — not
both branches' worth.
What the tool builds
Once witnesses are resolved, the tool satisfies the program against the spending transaction and writes the final Simplicity tapscript witness stack — exactly four items, in this order:
[ witness_bits, pruned_program, cmr_script, control_block ]
You never assemble this yourself; it's the output of finalisation. The Simplicity dry-run executes the program against this stack before broadcast, so a missing or wrong witness is caught locally rather than rejected by the network.
Not yet wired up
The spec and some
example files reference two further witness types — formula (a computed
value such as index_of(some_output)) and taproot_leaf (a leaf/control-block
selector). The current reference tool does not consume these — only
simplicityhl and Signature are processed, and the control block is derived from
the covenant's leaf structure regardless. Treat formula and taproot_leaf as
forward-looking until the tooling catches up; if you put them on an input today,
the witness is simply zeroed like any unsupplied value.
See also
Spec.md§8 — the full witness reference.- Multiple spending paths — using a
PATHselector witness in anger. - Covenant UTXO types — how the covenant address (and its tapleaf) is derived in the first place.
Worked example: a Last Will covenant
Problem. Lock funds so that the owner can move them with a hot key, escape the arrangement with a cold key, and — if the owner goes silent for 180 days — let an heir claim them. All three rules enforced on-chain.
This recipe puts the last few lessons to work on a real, non-trivial contract: the Last Will, adapted from the SimplicityHL examples. It has three spending paths, a relative timelock, and a recursive covenant — and it's a chance to see a multi-path witness selector in a complete file.
The three paths:
| Path | Who | When | Effect |
|---|---|---|---|
| Refresh | owner's hot key | any time | moves the funds but repeats the covenant |
| ColdBreak | owner's cold key | any time | spends out, ending the covenant |
| Inherit | heir's key | after 180 days of no movement | spends out to the heir |
The cold key is the escape hatch; the hot key is for everyday moves and is forced to re-lock; the inheritor is the dead-man's switch.
The program
The contract lives in
last_will.simf.
Two adaptations from the upstream example make it work with tx-manifest-wallet:
- The keys and the timelock are compile parameters (
param::INHERITOR_PUB_KEY,param::HOT_PUB_KEY,param::COLD_PUB_KEY, andparam::INHERIT_BLOCKS) instead of hardcoded constants, so the manifest can wire them — exactly likePUB_KEYin Hello World. - The path is chosen by a dedicated
SPEND_PATHwitness, and each signature is its own witness. The upstream version nested the signatures inside the selector; tx-manifest-wallet computes signatures as standaloneSignaturewitnesses, so we split them out (the idiom from Witnesses).
fn main() { match witness::SPEND_PATH { Left(inherit: ()) => inherit_spend(witness::INHERITOR_SIG), Right(cold_or_hot: Either<(), ()>) => match cold_or_hot { Left(cold: ()) => cold_spend(witness::COLD_SIG), Right(hot: ()) => refresh_spend(witness::HOT_SIG), }, } }
SPEND_PATH has type Either<(), Either<(), ()>>, so the three branches are
selected by Left(()), Right(Left(())), and Right(Right(())). Whichever
branch you take reads exactly one signature witness; the other two live on pruned
branches and are auto-zeroed.
The two interesting helpers:
#![allow(unused)] fn main() { fn inherit_spend(inheritor_sig: Signature) { let blocks: Distance = param::INHERIT_BLOCKS; // configurable timelock (a compile param) jet::check_lock_distance(blocks); checksig(param::INHERITOR_PUB_KEY, inheritor_sig); } fn recursive_covenant() { assert!(jet::eq_32(jet::num_outputs(), 2)); // exactly 2 outputs let this_script_hash: u256 = jet::current_script_hash(); let output_script_hash: u256 = unwrap(jet::output_script_hash(0)); assert!(jet::eq_256(this_script_hash, output_script_hash)); // output 0 = same covenant assert!(unwrap(jet::output_is_fee(1))); // output 1 = fee } }
inherit_spend enforces a relative timelock — the heir's spend is only valid
once the UTXO is INHERIT_BLOCKS blocks old (a compile param; ~180 days ≈ 25,920
one-minute Liquid blocks). recursive_covenant (used by the hot-key refresh)
forces the spend to recreate the same covenant in output 0 and have the explicit
fee in output 1, with nothing else.
The manifest
A will is something you deploy once and then operate — exactly what a
class models. We define a
last_will_contract class whose fields are the three keys, and whose
methods are the four actions. A constructor method (Fund) records the
keys in an instance file the first time you set the will up; the spend methods
read them back.
"classes": {
"last_will_contract": {
"fields": {
"INHERITOR_PUB_KEY": { "type": "pubkey" },
"HOT_PUB_KEY": { "type": "pubkey" },
"COLD_PUB_KEY": { "type": "pubkey" },
"INHERIT_BLOCKS": { "type": "u16" }
},
"methods": { "Fund": { ... }, "ColdBreak": { ... }, "Refresh": { ... }, "Inherit": { ... } }
}
}
The last_will UTXO type (top-level, as before) wires those three fields into the
program — the field names double as the compile params the script consumes:
"utxo_types": {
"last_will": {
"script": {
"type": "simplicity",
"source": "./last_will.simf",
"compile_params": {
"INHERITOR_PUB_KEY": "INHERITOR_PUB_KEY",
"HOT_PUB_KEY": "HOT_PUB_KEY",
"COLD_PUB_KEY": "COLD_PUB_KEY",
"INHERIT_BLOCKS": "INHERIT_BLOCKS"
}
},
"asset": "lbtc"
}
}
The constructor: Fund
Fund does double duty — it locks the funds and writes the instance file. It
takes the three keys as params (two auto-filled from your wallet) and an amount,
locks a wallet UTXO into the covenant, then create_instance records the keys:
"Fund": {
"is_constructor": true,
"params": {
"INHERITOR_PUB_KEY": {
"type": "pubkey",
"description": "The heir's x-only public key. They can claim the funds 180 days after the last move."
},
"HOT_PUB_KEY": {
"type": "pubkey",
"description": "Owner's hot key. Auto-filled from your wallet signing key."
},
"COLD_PUB_KEY": {
"type": "pubkey",
"description": "Owner's cold key. Your wallet's oracle key — the covenant escape hatch."
},
"INHERIT_BLOCKS": {
"type": "u16",
"default": "25920",
"description": "Blocks of inactivity before the heir may claim. ~180 days ≈ 25920 (1-minute Liquid blocks). Max 65535."
},
"amount_sat": {
"type": "u64",
"description": "Amount in satoshis to place under the will."
}
},
"inputs": [
{
"id": "funding_input",
"description": "Wallet UTXO providing the funds.",
"utxo_source": "wallet",
"asset": "lbtc",
"amount_sat": {
"min_amount": "params.amount_sat"
}
}
],
"outputs": [
{
"id": "will_out",
"description": "The funded last-will output.",
"destination": {
"utxo_type": "last_will"
},
"amount_sat": "params.amount_sat",
"asset": "lbtc"
},
{
"id": "change_out",
"description": "Change returned to the funding wallet.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
],
"create_instance": {
"class": "last_will_contract",
"fields": {
"INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY",
"HOT_PUB_KEY": "$params.HOT_PUB_KEY",
"COLD_PUB_KEY": "$params.COLD_PUB_KEY",
"INHERIT_BLOCKS": "$params.INHERIT_BLOCKS"
}
}
}
HOT_PUB_KEY auto-fills from your wallet signing key; COLD_PUB_KEY is your
wallet's oracle key (take it from info — see Setup);
the heir gives you INHERITOR_PUB_KEY. Each param value is written into the
compile params, so will_out's covenant address is computed from the keys you
just supplied — before the instance exists. After broadcast, create_instance
writes those same three keys into txmanifest.instance.json.
One instance per will. Unlike Hello World — which had no
classesand so no instance file — the keys here are a class's fields, persisted at construction. Every later spend reads them from the instance, so you only enter the keys once. This is the full class / instance model from Instance, state & constructors.
Each spend reads the instance
Every spend is a method whose input is the last_will UTXO (found in the state
file) with a SPEND_PATH selector and the matching Signature. The signature
keys reference compile_params.* — the fields loaded back from the instance file,
so you never re-enter them. ColdBreak:
"ColdBreak": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
"COLD_SIG": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
}
}
},
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
```json
"ColdBreak": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
"COLD_SIG": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
}
}
},
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "to_wallet", "destination": "wallet", "asset": "lbtc", "amount_sat": "will_in.amount_sat" },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
This is exactly the Hello World spend plus a
SPEND_PATH selector. Inherit is identical but with SPEND_PATH = Left(())
and INHERITOR_SIG.
Refresh is the one that's different — the covenant forces it to re-lock:
"Refresh": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Right(()))" },
"HOT_SIG": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "compile_params.HOT_PUB_KEY" } }
}
}
],
"outputs": [
{
"id": "will_again",
"destination": { "utxo_type": "last_will" },
"asset": "lbtc",
"amount_sat": "will_in.amount_sat - fee",
"required_index": 0
}
]
}
Three things the covenant dictates here:
required_index: 0—recursive_covenantchecksoutput_script_hash(0), so the re-locked output must be output 0.- No change output. The program asserts exactly two outputs (covenant + fee).
Because this action declares no
"change"output, the builder never adds one — it folds the L-BTC surplus into the fee. There's no separate fee input either: a recursive covenant can't add a wallet change output to return the leftover, so the will pays its own fee and shrinks by it each refresh. amount_sat: "will_in.amount_sat - fee"— thefeekeyword. The will is re-locked at its current value minus the network fee. The fee output (output- then ends up being exactly
fee.
- then ends up being exactly
The
feekeyword.feeis a reserved formula word for the estimated network fee. It evaluates to0while the outputs are first assembled, then the tool estimates the fee from the transaction's size and re-evaluates any amount that usedfee— sowill_in.amount_sat - feelands on the right value before signing. Nofee_satparam to guess at.
How the builder decides on change. A change output is added only when an action lists a
"destination": "change"output. Methods that omit it — likeRefresh— get exactly their declared outputs plus the fee, which is what a recursive covenant needs.
Skip the prompts: a params file
Fund needs three pubkeys. Typing them in is error-prone, so the CLI can read
them from a params file: a flat JSON object of param → value that pre-fills
(or fully supplies) the prompts.
You don't even pass a flag. The tool auto-discovers a file named
<stem>.<network>.json next to the manifest (the <stem> is the manifest's
filename stem) — so for testnet it loads txmanifest.testnet.json from
examples/last_will/:
{
"INHERITOR_PUB_KEY": "…heir's wallet pubkey…",
"HOT_PUB_KEY": "…owner's wallet pubkey…",
"COLD_PUB_KEY": "…owner's oracle pubkey…",
"INHERIT_BLOCKS": "25920",
"amount_sat": "100000"
}
(An explicit --params <file> works too, and overrides the auto-discovered one.)
In this example the heir is a second wallet, so two of the keys come from one
wallet and one from another. Rather than copy three pubkeys out of info by hand,
the book ships scripts that do it for you:
.\create_wallet.ps1 # owner wallet -> wallet.json
.\create_inherit_wallet.ps1 # heir wallet -> wallet-inherit.json
.\make_params.ps1 # reads both, writes examples/last_will/txmanifest.testnet.json
make_params.ps1 runs info on each wallet, pulls out the signing and oracle
pubkeys, and writes the params file — INHERITOR_PUB_KEY from the heir wallet,
HOT_PUB_KEY / COLD_PUB_KEY from the owner wallet. (HOT_PUB_KEY also
auto-fills from the wallet at run time, since it's a wallet_key source; the file
just makes every value explicit.)
Run it
Run everything from the repository root, where the scripts put the wallets and
params file. With the params file in place, construct the will — Fund reads
every value from the file, so there's nothing to type. It locks the funds and
writes examples/last_will/txmanifest.instance.json:
txw run examples/last_will/txmanifest.json Fund \
--network testnet --wallet wallet.json
Then the cold-key break-out is the most straightforward spend to run, since your oracle key signs it — and you don't re-enter any keys, because they're read from the instance:
txw run examples/last_will/txmanifest.json ColdBreak \
--network testnet --wallet wallet.json
ColdBreak finds the last_will UTXO in the state file, rebuilds the covenant
address from the instance's three fields, signs with the cold (oracle) key,
dry-runs the program down the Right(Left(())) branch, and broadcasts.
Caveats on the other two paths.
RefreshandInheritexercise covenant features the reference tool doesn't fully drive yet:
Inheritneeds the input's relative timelock (nSequence) set to ≥180 days forcheck_lock_distanceto pass, and is signed by the heir's key — not your wallet. It's the dead-man's-switch path; treat it as illustrative until relative-locktime support lands.Refreshrelies on the explicit-fee / no-change output layout with thefeekeyword. The estimate adds a fixed allowance for the covenant's Simplicity witness (whose exact size is only known after signing), so it errs slightly high to stay above the relay minimum — the extra just goes to the fee. Fund the will with a little headroom so each refresh's fee fits.
Try next
You've now seen multiple spending paths, a timelock, and a recursive covenant in one file. The recipes that go deeper on those building blocks: Covenant UTXO types and Multiple spending paths.
Covenant UTXO types
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Define an on-chain state whose address is a Taproot output built from one or more Simplicity programs.
🚧 This recipe is a stub. Outline of what it will cover:
- The
scriptblock as the tool reads it:type: "simplicity", asourcepath to the.simffile, and acompile_paramsmap wiring manifest params onto the program'sparam::*names (e.g.{ "PUB_KEY": "PUBKEY" }).- How the tool turns that into an address: compile the
.simf→ CMR → a Taproot output with aNUMSinternal key, so the key-path is unspendable and every spend goes through the script.canonical_cmr: the CMR with params zeroed — a stable identifier a wallet uses to recognize the program independent of instance parameters.- Covenant address determinism: same
.simf+ same params → same address, always. TheP2TR(NUMS, tapbranch(...))construction.extra_leavesfor appending additional taproot leaves.
See Spec.md §14 "Covenant Address Determinism"
in the meantime.
Multiple spending paths
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Build a covenant that can be spent in more than one way — e.g. a cooperative path and a cancel/timeout path — and select between them at spend time.
🚧 This recipe is a stub. Outline of what it will cover:
- A Simplicity program with
Either<(), ()>paths (PATH::LEFT/PATH::RIGHT).- Selecting a path with a
simplicityhlwitness:Left(())vsRight(()).- Worked example: the lending
pre_lockcovenant —SetupLendingtakes the left path;CancelOffertakes the right path with a borrower signature.- Using
required_indexon inputs so the covenant's introspection lines up.- Cooperative vs unilateral paths, and how they show up in
lifecycle.
See the pre_lock discussion in the
lending walkthrough in the meantime.
Formulas & derived params
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Compute amounts, indices, and parameter values from other values instead of hard-coding them.
🚧 This recipe is a stub. Outline of what it will cover:
- Where formulas appear: output/input
amount_sat, validationexpr, hooksetvalues, witnessexpr.- Operators (
+ - * /, comparisons,&& || !) and references (compile_params.X,params.X,input_id.amount_sat,input_id.asset,input_id.present).- Functions:
pow(base, exp),index_of(id),concat(...).- The special
feesvalue used in change formulas.- Derived params (
"derived": true): interest =PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000, and params derived from issuance outpoints.
See Spec.md §9
for the formula grammar in the meantime.
Asset issuance & NFTs
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Mint Liquid assets — including single-unit NFTs used as bearer tokens — as part of an action, and derive their asset IDs.
🚧 This recipe is a stub. Outline of what it will cover:
- The input
issuancespec:kind(new/reissue),asset_amount_sat,inflation_amount_sat.- Why an asset ID is derived from the outpoint of the issuance input, and how to reference the result as
input_id.assetafterward.- NFTs as bearer tokens (amount = 1) authorising covenant paths.
- Encoding protocol terms in an NFT's amount field via bit-packing (the lending protocol's parameter NFTs).
- Burning NFTs with
OP_RETURNto prevent reuse.
See the IssueNFTs action in the
lending walkthrough in the meantime.
Hooks & tapleaf compute
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Compute and store values mid-action — especially covenant script hashes that depend on other covenant script hashes.
🚧 This recipe is a stub. Outline of what it will cover:
- Hook blocks:
on_resolved(per input) andon_pre_broadcast(per action), each runningsetassignments in declaration order.- Assignment targets:
compile_params.X,params.X,args.X.- The tapleaf compute spec (
lang: "tapleaf"): compiling a.simfto a covenant script hash, withparamsanddepends_on.- Circular dependencies: when two covenants each reference the other's hash, seed with 32 zero bytes and iterate to convergence.
See Spec.md §5.5 and §11 Step 3
in the meantime.
Instance, state & constructors
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Problem. Deploy a contract once and then act on it repeatedly — persisting the compile params and tracking the live UTXO set across transactions.
🚧 This recipe is a stub. Outline of what it will cover:
- The three-file model in practice: manifest / instance / state.
- Classes: grouping methods under a typed contract with
fields.- Constructors (
is_constructor: true) andcreate_instance: writing the instance file with resolved field values.- The state file: how covenant outputs are added and spent inputs removed after each broadcast.
provided_inputs: pre-filling a counterparty's UTXO inline (the website-to-wallet integration pattern).
See Spec.md §12–13
in the meantime.
The lending protocol
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
🚧 This chapter is a stub. It will tie every cookbook concept together on a real peer-to-peer collateralised lending protocol on Liquid: NFT ownership, Simplicity covenants, partial repayments, vault accumulation, and liquidation.
The complete lending example lives in the repository at
examples/lending/
(its txmanifest.json
plus the covenant .simf programs). This chapter will adapt it into the cookbook,
cross-linking each section back to the recipe that introduced the concept:
- Lifecycle → recipe 6
- Params (compile, derived, NFT-encoded) → recipes 2 & 7
- UTXO types (covenants, script-auth) → recipe 5
- IssueNFTs → recipe 8
- LockCollateral, SetupLending, RepayLoan, … → recipes 3, 4 & 9
Field type reference
The type strings used in compile_params, class fields, and action params.
| Type string | Rust equivalent | Description |
|---|---|---|
u8 | u8 | 8-bit unsigned integer |
u16 | u16 | 16-bit unsigned integer |
u32 | u32 | 32-bit unsigned integer |
u64 | u64 | 64-bit unsigned integer |
bytes32 | [u8; 32] | 32-byte raw byte array |
pubkey | [u8; 32] | 32-byte x-only BIP340 Schnorr public key |
liquid.asset_id | [u8; 32] | Liquid asset ID (32 bytes) |
address | string | A bech32/blech32 address (used by action params) |
Integer field values are written as decimal strings in instance files; byte types as hex strings. See
Spec.md§4.2 and §12.
Formula language reference
Formulas are string expressions evaluated at transaction build time. They appear
in output/input amount_sat, validation expr, hook set values, and witness
expr.
Operators
| Operator | Description |
|---|---|
+ - * / | Integer arithmetic (division truncates) |
== != < <= > >= | Comparison (returns boolean) |
&& || ! | Boolean logic |
( ) | Grouping |
References
| Syntax | Description |
|---|---|
compile_params.NAME | Compile parameter by name |
params.NAME | Action parameter by name |
args.NAME | Action argument by name |
input_id.amount_sat | Satoshi amount of a resolved input |
input_id.asset | Asset ID of a resolved input (hex string) |
input_id.present | Boolean — whether an optional input was found |
output_id.amount_sat | Satoshi amount of a constructed output (post-construction) |
fees | Estimated transaction fee (used in change formulas) |
Functions
| Function | Signature | Description |
|---|---|---|
pow(base, exp) | (u64, u64) → u64 | Integer exponentiation |
index_of(id) | (input or output id) → u32 | Transaction index of a named input/output |
concat(a, b, …) | (bytes…) → bytes | Byte concatenation (OP_RETURN data only) |
See Spec.md §9
for the authoritative grammar.
CLI reference
The tx-manifest-wallet CLI (txmanifest_wallet)
executes manifest actions interactively. This book invokes it as txw <subcommand>
(an alias for tx-manifest-wallet — see Setup for
the install options). Manifest paths are relative to your current directory.
Commands
validate <manifest>
Statically check a manifest's schema and report obvious problems — without
touching the network, wallet, or filesystem. Catches unknown utxo_type
references, outputs missing a required amount_sat, duplicate input/output/
validation ids, malformed destinations, unknown validation rule types,
create_instance referencing a missing class, unreferenced UTXO types, and
lifecycle transitions that don't match any action. Exits non-zero if any errors
are found (warnings alone still exit zero).
txw validate examples/p2pk/txmanifest.json
Future versions will add deeper checks (compiling SimplicityHL leaves, verifying formula references resolve, checking
canonical_cmrvalues).
describe <manifest>
Explore a manifest interactively. Presents a menu of the contract's overview,
classes, and standalone actions; drill into any class to list its fields and
methods, and into any action to see its params, inputs, outputs, witnesses, and
validations — without reading the raw JSON. When stdout is not a terminal (e.g.
piped to a file or less), it prints a full non-interactive dump of everything
instead.
txw describe examples/lending/txmanifest.json
run <manifest> <action>
Walk through the lifecycle of a manifest action interactively: resolve params and inputs, validate, build the PSET, dry-run the Simplicity covenant, sign, and broadcast.
| Flag | Default | Purpose |
|---|---|---|
--network <net> | config default_network | Network for param-file auto-discovery. |
--params <file> | — | Flat JSON string→string overrides (takes precedence over auto-discovered file). |
--wallet <file> | wallet.json | Wallet for input selection and signing. |
--data-dir <dir> | platform data dir | Where wallet state is persisted. |
--instance <file> | <stem>.instance.json | Instance file (compile params locked at deploy). |
--state <file> | <stem>.state.json | State file tracking live UTXOs. |
--manual-inputs | off | Prompt for every input instead of auto-selecting. |
--export-pset <file> | — | Write signed PSET/tx to a file instead of broadcasting. |
--debug-jets | off | Print every Simplicity jet call during dry-runs. |
create-wallet
Create a new wallet JSON file. --out <file> (default wallet.json),
--mainnet <bool> (defaults to config network).
info
Show wallet fingerprint, master xpub, oracle pubkey, and a receive address.
--wallet <file>.
sync
Sync wallet state against an Esplora server and print the balance.
--wallet <file>, --esplora <url>, --data-dir <dir>.
get-balance
Print the last known balance from persisted state (no network call).
--wallet <file>, --data-dir <dir>.
prepare <manifest> <action>
Ensure the wallet has the UTXOs an action needs; broadcasts a split transaction if
not. --wallet, --esplora, --data-dir, --split-amount <sats> (default
10000).
split
Split a wallet asset into N equal UTXOs and broadcast. -n/--count <N>,
--asset <hex|lbtc> (default lbtc), --amount-each <sats> (optional — splits
balance evenly if omitted), --wallet, --esplora, --data-dir.
config [key] [value]
With no args, print config. With key value, set it. Valid keys:
default_network (testnet|mainnet), default_esplora (URL).
Typical session
txw config default_network testnet
txw config default_esplora https://blockstream.info/liquidtestnet/api
txw create-wallet --out wallet.json
txw info --wallet wallet.json # → fund this address
txw sync --wallet wallet.json
txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json
txw run examples/p2pk/txmanifest.json Pay --network testnet --wallet wallet.json
Wallet implementation guide
Audience: Wallet implementors consuming manifests to build and sign transactions.
The tx-manifest-wallet CLI used throughout this book is an
example implementation of the lifecycle described here. Any wallet can consume
a manifest by following the same steps. This page describes the execution
lifecycle a wallet follows when executing an action from a manifest. Field
definitions are not duplicated here; refer to
Spec.md for the
authoritative field reference.
Lifecycle
The following steps are executed in order for each action execution.
1. Parse
Read compile_params.user_provided and the target action's params and args.
Determine which values the user must supply upfront. Values already fixed by
provided_inputs.params are excluded from user prompting.
2. User inputs args and params
Prompt the user for all required args and params values not already covered
by provided_inputs. Present description fields as guidance text.
3. Input selection
For each input in the action's inputs array, attempt to auto-select a UTXO
satisfying the input's utxo_source, asset, and amount_sat constraints.
Inputs already fixed by provided_inputs.inputs are used verbatim — do not
prompt for these.
- For ambiguous cases (multiple candidates) or when auto-select is disabled by wallet policy, prompt the user to choose.
- User may opt into auto-select depending on wallet implementation.
- Validate each
provided_inputsUTXO against chain state: confirm it is unspent and itsscript_pubkeymatches the expected script.
4. on_input_resolved hooks run
Execute all hooks declared in hooks.on_input_resolved, in declaration order
(the order they appear in the file). This is the only ordering guarantee.
Each hook is keyed by input id and runs a SimplicityHL program that sets one or
more compile_params.DERIVED_PARAM values. The execution context available to
each hook:
- Resolved input outpoints (txid, vout), amounts, and assets for all inputs resolved so far.
- All
compile_paramsset to date, including values set by earlier hooks in the same pass.
All hooks must complete before any validation runs. Subsequent validations and output formulas may depend on the derived params set here.
On-chain context jets (current_index, input_script_hash, etc.) are
not available at build time. Those jets execute only during on-chain script
evaluation, not during transaction construction.
5. Outputs constructed
Build the transaction outputs from the action's outputs array. Evaluate each
amount_sat formula using the now-complete compile_params context
(user-provided plus all hook-derived values), resolved input amounts, and action
args/params. Resolve output destination fields to concrete scriptPubKeys.
6. Fee rate chosen and applied
Estimate the transaction fee or prompt the user for a fee rate. Apply the fee to
the transaction, adjusting any "change" output accordingly.
7. on_validate hook runs (if present)
If the action declares an on_validate hook, run the full SimplicityHL program
against the current transaction state. The program returns Option<u16>:
None— validation passes; continue.Some(n)— validation fails with error coden; look upnin the top-levelerrorsmap and surface the description to the user. Flow returns to step 3.
8. 1-liner validations run
Execute each entry in the action's validations array, in declaration order.
Each validation evaluates its rule against the current transaction state.
- A failing
arithmeticorsimplicity_hlvalidation produces an error code from the entry'serror.codefield. - A failing
utxo_existsvalidation produces the same.
9. On any validation error
Look up the error code (string key) in the top-level errors map to obtain the
English-language description. Surface this to the user. The user adjusts their
inputs or params and flow returns to step 3.
10. Fee review / adjustment → PSET created
Present the user with the final fee amount. If the user adjusts the fee rate, rerun from step 6. Signatures are not yet present at this point, so there is no witness-invalidation problem.
Once the user confirms, construct the PSET. This is the boundary between manifest-level reasoning and standard Elements/Bitcoin wallet machinery. A wallet that does not implement tx-manifest can receive the PSET from this point onwards and handle signing and broadcast normally.
11. Wallet signs
Populate witnesses into the PSET per the action's witnesses map. For each
witness descriptor, produce the required data (signatures, preimages,
SimplicityHL-typed values, etc.) as specified by the source type. Pre-computed
witnesses from provided_inputs.witnesses are included verbatim.
12. Simplicity dry-run
Execute the covenant scripts on all inputs against the signed PSET. This is a local simulation of on-chain script execution; it does not broadcast.
A dry-run failure indicates a bug in the manifest or wallet implementation, not a user error. Surface it as an internal error with the relevant input index and script. Do not ask the user to retry.
This step is distinct from the manifest validations in steps 7–8. Manifest validations are pre-flight business-logic checks expressible without a full Simplicity interpreter. The dry-run is the final cryptographic and covenantal correctness check, confirming that the on-chain scripts will accept the constructed transaction.
13. Broadcast
Finalise and extract the transaction from the PSET. Broadcast to the network, or hand off to an external broadcast service.
Execution context for SimplicityHL code
The following are available to all SimplicityHL code at build time (hooks and validations):
| Available | Description |
|---|---|
| Resolved input outpoints | txid and vout for each resolved input |
| Resolved input amounts and assets | amount_sat and asset for each resolved input |
compile_params | All user-provided values plus any values set by hooks that have already run |
Action args and params | Runtime values supplied by the user |
The following are not available at build time:
| Not available | Reason |
|---|---|
current_index, input_script_hash, and other introspection jets | These are on-chain execution context — they only exist when a Simplicity program runs inside the node during transaction validation, not during wallet-side transaction construction. |
Error codes
Error codes are u16 values. The manifest's top-level errors field maps
numeric codes to English-language descriptions:
"errors": {
"1001": "Collateral amount is below the minimum required for this loan.",
"1002": "Loan has not yet expired; liquidation is not permitted."
}
Both on_validate (step 7) and per-entry validations (step 8) produce error
codes. The wallet looks up the code in errors and displays the description to
the user.
Localisation. Other locales are provided as separate JSON files sharing the same numeric keys — the manifest itself carries only the English descriptions. Wallet implementations that support multiple locales load the appropriate locale file and index into it by the same code.
Notes on provided_inputs
When a manifest arrives with a provided_inputs section (e.g. from a DEX
front-end or counterparty):
- Treat every entry in
provided_inputs.inputsas fixed — do not prompt the user to select these UTXOs. - Treat every entry in
provided_inputs.paramsas fixed — do not prompt the user for these values. - Include every entry in
provided_inputs.witnessesverbatim in the PSET — do not re-derive or overwrite. - Validate all provided UTXOs against chain state before proceeding (step 3).
- Validate pre-computed witnesses cryptographically before including them (step 11).
provided_inputs data arrives from an untrusted source. See
Spec.md Section
17 for the full security requirements.
Glossary
Action / Method — A single transaction recipe in a manifest: its inputs, outputs, witnesses, and validations. Action (top-level) and method (inside a class) are structurally identical.
Attestation — A BIP340 signature over the finalized manifest by a developer, auditor, or counterparty. Tampering invalidates it.
Class — A typed contract definition with named fields and methods. Each
deployed instance of a class has its own instance file.
CMR (Commitment Merkle Root) — The 32-byte hash of a compiled Simplicity program. Doubles as the program's on-chain identity.
canonical_cmr — The CMR of a program with all parameters zeroed. A stable
identifier for the program's structure, independent of instance parameters.
Compile param — A value baked into a covenant script at deploy time. Changing one changes the script's address. Stored in the instance file.
Manifest — The static JSON protocol definition (txmanifest.json).
Covenant — A script that constrains how its output may be spent — e.g. by introspecting the spending transaction's inputs and outputs.
Derived param — A compile param computed by the tool rather than supplied: from a formula, or from the outpoint of an issuance input.
Instance file — Per-deployment compile params and class field values
(<name>.instance.json).
NUMS point — "Nothing Up My Sleeve" — a public key with no known private key, used as a Taproot internal key to make the key-path provably unspendable.
provided_inputs — UTXOs pre-filled inline in the instance file, letting a
wallet spend a counterparty's output it never indexed.
PSET — Partially Signed Elements Transaction (the Elements equivalent of a PSBT).
State file — The live on-chain UTXO set for one instance
(<name>.state.json), updated after every broadcast.
Tapleaf compute spec — A field value (lang: "tapleaf") that compiles a
.simf file with params to produce a covenant script hash.
UTXO type — A named on-chain state with a known script, so a wallet can recognise the protocol's outputs.
Witness — A value supplied to satisfy a Simplicity program when spending: a signature, a path selector, a leaf selector, or a computed value.