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
Everything a wallet needs to build the protocol's transactions without reading the covenant source: the contract types and their compile-time parameters, the on-chain states those contracts can create, the valid transactions between them, and the state machine tying it together.
Anatomy of a manifest dissects each section in turn. 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 }
],
"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
Accepting or cancelling the offer (and the
lending covenant in Settling the loan)
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
Problem. Mint a brand-new Liquid asset — including a single-unit NFT — as part of an action, and use its derived asset ID in the same transaction and in later ones.
Every recipe so far has moved assets that already existed (L-BTC, a covenant's
collateral). This one creates them. On Liquid, an asset is issued as a property
of a transaction input: you point at a wallet UTXO, attach an issuance block, and
the transaction mints a new asset whose ID is derived from that input. Two ideas do
most of the work:
- The new asset's ID comes from the outpoint of the issuing input — so it's unique and unforgeable, but unknown until the input is chosen.
- You capture that ID with an
on_resolvedhook so the rest of the action (and the instance file) can refer to it.
The only example manifest that issues assets is the lending protocol, so the
snippets below are drawn from its IssueUtilityNFTs constructor — reduced to one
asset at a time. The lending walkthrough
shows all four issuances together.
The issuance block
Add issuance to any wallet input to mint an asset as that input is spent:
{
"id": "nft_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 }
}
| Field | Required | Purpose |
|---|---|---|
kind | yes | "new" for a first issuance, "reissue" to mint more of an existing asset. |
asset_amount_sat | yes | How many units to issue, in the asset's base denomination. A literal or a formula. |
inflation_amount_sat | no | Inflation (reissuance-token) amount. 0 disables reissuance — the supply is fixed forever. |
The input is still an ordinary input: it's a wallet UTXO you also spend for its
L-BTC (here, to pay the fee). The issuance block just rides along on it.
Why the asset ID comes from the outpoint
A Liquid asset ID is computed from the outpoint (txid + vout) of the input that issues it. That makes the ID globally unique without a registry — no two inputs can ever share an outpoint — but it has a practical consequence:
One issuance per input, and the ID isn't known up front. To mint N distinct assets in one transaction you need N distinct input UTXOs. And because the ID depends on which UTXO the wallet picks, you can't hard-code it — you compute it during the build and capture it (next section).
This is why the lending protocol ships a Prepare helper action that splits one
wallet UTXO into four before IssueUtilityNFTs runs: four NFTs need four separate
inputs to issue from.
Capturing the new asset ID with on_resolved
Once the build picks the input's UTXO, its outpoint — and therefore the new asset
ID — is fixed. An on_resolved hook on the input fires at that moment and lets you
stash the ID into a compile param:
{
"id": "nft_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 },
"on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } }
}
Inside an input's own on_resolved, the bare word asset means this input's
resolved asset ID — the freshly minted one. Elsewhere in the action you refer to it
by the input's id, as nft_issuance_input.asset (the general
formula reference form). From here on,
compile_params.BORROWER_NFT_ASSET_ID is a normal param: you can lock outputs to
it, feed it into a covenant's compile params, or write it into the instance file.
Hooks recap.
on_resolvedruns per-input as soon as that input's UTXO is known;on_pre_broadcastruns once per action just before building. Both runsetassignments. See Hooks & tapleaf compute.
Run it
Issuance has no standalone example manifest — it's exercised by the lending
constructor. IssueUtilityNFTs needs four separate L-BTC UTXOs (one per
issuance), which Prepare carves out first:
txw run examples/lending/txmanifest.json Prepare \
--wallet borrower.json
txw run examples/lending/txmanifest.json IssueUtilityNFTs \
--wallet borrower.json
txw sync --wallet borrower.json
txw get-balance --wallet borrower.json # four new single-asset balances
After it broadcasts, the wallet holds four newly minted assets and the instance
file records their IDs. To see the transaction's outputs (including the issuance
outputs) before broadcasting, add --export-pset issue.pset.json and decode it.
Try next
You've minted assets and captured their derived IDs. The full four-NFT construction — plus packing loan terms into amounts and computing the covenants those NFTs get locked to — is the first phase of the lending walkthrough: Issuing the NFTs & encoding the terms. The hooks that capture and derive these values get their own recipe: Hooks & tapleaf compute.
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 (
compute: "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.
Problem. Two strangers want to transact a collateralised loan with no escrow agent and no trusted backend. A borrower locks collateral and advertises terms; a lender supplies the principal; the loan later settles by repayment or — if the borrower defaults — by liquidation after a deadline. Every rule is enforced on-chain by Simplicity covenants.
This is the capstone of the cookbook. Everything the recipes introduced one at a time — covenant UTXO types, multiple spending paths, asset issuance & NFTs, formulas & derived params, hooks & tapleaf compute, and the class / instance model — shows up here at once, wired into a single working protocol.
The full example lives in the repository at
examples/lending/:
one txmanifest.json
plus five .simf covenant programs. This chapter walks through it in the order
you'd actually run it.
The deal, in one paragraph
Alice has collateral (say, L-BTC) and wants to borrow L-USDT against it without selling. She locks her collateral into a covenant and publishes her terms — amount, interest, expiry — as on-chain NFTs anyone can read. Bob sees the offer, likes the terms, and accepts by sending Alice the principal; in the same transaction her collateral moves into a second covenant that holds it for the life of the loan. To get her collateral back, Alice repays principal plus interest before the deadline. If she doesn't, Bob can seize the collateral once the deadline passes. At no point does either party have to trust the other or any third party — the covenants only permit the honest transitions.
The cast
Two roles. The protocol has a borrower and a lender. They are
different people with different wallets, so when you run it you'll keep two wallet
files — borrower.json and lender.json — and run each action as the
appropriate party.
Five covenant programs. Each .simf file is a small Simplicity program that
gates one kind of UTXO. They are deliberately tiny and composable:
| Program | Role |
|---|---|
pre_lock.simf | Holds the collateral while the offer is open. PATH::LEFT lets a lender activate the loan; PATH::RIGHT lets the borrower cancel (with a signature). |
lending.simf | Holds the collateral during the active loan. PATH::LEFT is repayment; PATH::RIGHT is liquidation after expiry. |
script_auth.simf | Wraps each NFT so it can only be spent co-spent with the right collateral covenant. The glue that binds the NFTs to the deal. |
asset_auth.simf | Guards the lender's principal vault: release requires burning the Lender NFT. |
p2pk.simf | The borrower's plain Schnorr payout address — the Hello World program, reused for where the principal lands. |
Four NFTs. The protocol mints four single-unit Liquid assets at construction. Two are bearer auth tokens (whoever holds it can act); two encode the loan terms in their amount field so the offer is self-describing on-chain:
| NFT | Carries | Used for |
|---|---|---|
| Borrower NFT | nothing (amount = 1) | proves a transaction is the borrower's; co-spent in setup and repayment |
| Lender NFT | nothing (amount = 1) | the lender's bearer token; needed to liquidate and to drain the vault |
| First Parameters NFT | interest rate, expiry, decimals — bit-packed into its amount | publishes the loan terms; checked by every covenant |
| Second Parameters NFT | collateral & principal base amounts — bit-packed into its amount | publishes the amounts; checked by every covenant |
The lifecycle
The whole protocol is one lending_contract class, and the manifest closes
with an optional lifecycle block that names the state machine its methods walk
through:
"lifecycle": {
"states": ["nfts_issued", "offer_open", "loan_active", "repaid", "liquidated", "cancelled"],
"entry_actions": ["IssueUtilityNFTs"],
"transitions": {
"IssueUtilityNFTs": { "to": "nfts_issued" },
"LockCollateral": { "from": "nfts_issued", "to": "offer_open" },
"CancelOffer": { "from": "offer_open", "to": "cancelled", "unilateral": true },
"SetupLending": { "from": "offer_open", "to": "loan_active" },
"RepayLoan": { "from": "loan_active", "to": "repaid", "cooperative": true },
"LiquidateAfterExpiry": { "from": "loan_active", "to": "liquidated", "unilateral": true },
"ClaimPrincipalWithInterest": { "from": "repaid", "to": "settled" }
}
}
lifecycleis documentation-only. The spec lists it among the top-level fields as "named states, transitions, execution paths" and marks it purely informative — nothing on-chain depends on it, and no tool is required to enforce it. It exists so a reader (or a diagram renderer) can see the intended state machine at a glance without tracing every method's inputs and outputs. It may be dropped from a future revision; treat it as a map, not machinery.
The block has three parts:
states— the named states an instance can be in. They're free-form labels; thefrom/tofields below refer to them. (settledappears as atotarget without being listed — a reminder that this section is descriptive, not validated.)entry_actions— the methods that create a fresh instance rather than advancing an existing one. Here it's the constructor,IssueUtilityNFTs.transitions— one entry per method, each naming the state it moves from and to, plus two optional flags described below. A transition with nofrom(the constructor) is an entry point.
Rendered, those transitions are the protocol's flow:
IssueUtilityNFTs
│ (borrower mints 4 NFTs + computes covenant hashes)
▼
┌───────────┐
│ nfts_issued│
└───────────┘
│ LockCollateral (borrower)
▼
┌───────────┐ CancelOffer (borrower, unilateral)
│ offer_open │ ─────────────────────────────► cancelled
└───────────┘
│ SetupLending (lender accepts)
▼
┌───────────┐
│loan_active │
└───────────┘
│ │
RepayLoan │ │ LiquidateAfterExpiry
(borrower, │ │ (lender, unilateral,
cooperative) ▼ ▼ after LOAN_EXPIRATION_TIME)
┌────────┐ ┌───────────┐
│ repaid │ │ liquidated│
└────────┘ └───────────┘
│
ClaimPrincipalWithInterest (lender drains the vault)
▼
settled
The two optional flags annotate who a transition needs. "unilateral": true
marks the two escape hatches — CancelOffer and LiquidateAfterExpiry — that one
party can take without the other's cooperation; that's the whole point of a
trustless protocol, the exits don't depend on the counterparty playing along.
"cooperative": true marks RepayLoan as the happy path both sides want. The
flags don't do anything on-chain — the covenants are what actually enforce who
can spend — but they tell a reader at a glance which transitions are adversarial
and which are mutual.
How this chapter is organised
The walkthrough follows the lifecycle across four pages, each building one phase and pulling in the recipes that introduced its pieces:
- Issuing the NFTs & encoding the terms — the
IssueUtilityNFTsconstructor: minting four NFTs from issuance inputs, bit-packing the loan terms into Parameter NFT amounts, and computing the web of interdependent covenant hashes that every later step relies on. - Opening the offer —
LockCollateralputs the collateral and NFTs on-chain behind thepre_lockcovenant, with anop_returndiscovery beacon and a pre-buildvalidationscheck. - Accepting or cancelling the offer — the two spending
paths of
pre_lock: the lender'sSetupLending(with therequired_indexdiscipline a covenant demands) versus the borrower'sCancelOffer. - Settling: repay, liquidate, withdraw — the
borrower's
RepayLoanversus the lender'sLiquidateAfterExpiryon thelendingcovenant, then draining the principal vault withClaimPrincipalWithInterest.
Before you run it
The CLI is tx-manifest-wallet, aliased throughout the book to txw. Do the
one-time setup first. Because this protocol has two
roles, create two wallets and fund both from the testnet faucet:
txw create-wallet --out borrower.json
txw create-wallet --out lender.json
# fund each from https://liquidtestnet.com/faucet, then:
txw sync --wallet borrower.json
txw sync --wallet lender.json
Where's the funding address? Run
infoon each wallet and copy the receive address it prints, then paste that into the faucet:txw info --wallet borrower.json # copy the receive address, fund it, repeat for lender.jsonSee Fund and sync for the full walk through.
Get oriented with describe and validate before building anything — describe
prints the classes, methods, and lifecycle; validate checks the manifest is
internally consistent:
txw describe examples/lending/txmanifest.json
txw validate examples/lending/txmanifest.json
Then start with Issuing the NFTs.
This is the most involved example in the book. If you haven't worked through Hello World and the Last Will covenant yet, do those first — they introduce the single-key and multi-path patterns this protocol composes at scale.
Issuing the NFTs & encoding the terms
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Phase 1 of the lending walkthrough. The borrower constructs a loan offer: mint four NFTs, pack the loan terms into two of them, compute the covenant addresses the rest of the protocol locks to, and write it all into an instance file.
Nothing is on-chain as a loan yet after this step — IssueUtilityNFTs only
mints the tokens and records the parameters. But it's the densest action in the
protocol, because it's where four cookbook ideas converge:
- Issuance — minting new Liquid assets whose IDs come from input outpoints (recipe 8).
- Bit-packing — encoding the loan terms into NFT amount fields so the covenants can read them on-chain (recipe 7).
- Tapleaf compute — deriving each covenant's script hash by compiling a
.simf, where some hashes feed into others (recipe 9). - A constructor —
create_instancepersists the whole deal to a file so every later method can read it back (recipe 10).
The constructor and its terms
IssueUtilityNFTs is the class's is_constructor method. Its params are the
loan terms the borrower chooses:
"IssueUtilityNFTs": {
"is_constructor": true,
"params": {
"BORROWER_PUB_KEY": { "type": "pubkey", "source": { "type": "wallet_key" } },
"COLLATERAL_ASSET_ID": { "type": "liquid.asset_id" },
"COLLATERAL_AMOUNT": { "type": "u64" },
"COLLATERAL_DECIMALS_MANTISSA":{ "type": "u8", "default": "8" },
"PRINCIPAL_ASSET_ID": { "type": "liquid.asset_id" },
"PRINCIPAL_AMOUNT": { "type": "u64" },
"PRINCIPAL_DECIMALS_MANTISSA": { "type": "u8" },
"PRINCIPAL_INTEREST_RATE": { "type": "u16" },
"LOAN_EXPIRATION_TIME": { "type": "u32" }
},
...
}
BORROWER_PUB_KEY auto-fills from the borrower's wallet signing key (the
wallet_key source from Parameters).
The interest rate is in basis points (u16, so 10,000 = 100%); the
expiry is a block height (CLTV). The two DECIMALS_MANTISSA values matter for
the encoding below — they let a base-10 amount like "1 L-BTC" be stored compactly
as 1 with a separate exponent of 8.
Minting four NFTs from issuance inputs
New to issuance? This is the first action in the book that mints assets. Asset issuance & NFTs covers the
issuanceblock, why asset IDs come from outpoints, and bearer-token NFTs in isolation — read it first if any of the below is unfamiliar.
A Liquid asset ID is derived from the outpoint of the input that issues it, so
to mint four distinct NFTs you need four distinct wallet UTXOs. That's why the
helper Prepare action splits one UTXO into four beforehand. Each issuance input
declares an issuance block and captures the resulting asset ID with an
on_resolved hook:
{
"id": "borrower_nft_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 },
"on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } }
}
The Borrower and Lender NFTs are issued with asset_amount_sat: 1 — true
single-unit bearer tokens. The two Parameter NFTs are different: their issued
amount is not 1 but the encoded loan terms (next section), so the asset's very
supply carries the offer:
{
"id": "first_params_issuance_input",
"utxo_source": "wallet",
"asset": "lbtc",
"issuance": { "kind": "new", "asset_amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 },
"on_resolved": { "set": { "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } }
}
All four asset IDs land in compile_params.* via on_resolved, ready for the
covenant-hash computation. The matching outputs simply send each freshly minted
NFT back to the borrower's wallet. See
Asset issuance & NFTs for the issuance
mechanics in isolation.
Bit-packing the loan terms
Here's the trick that makes the offer self-describing on-chain. Rather than store
the terms off-chain, the protocol packs them into the amount fields of the two
Parameter NFTs, computed in an on_pre_broadcast hook before the transaction is
built:
"on_pre_broadcast": {
"set": {
"compile_params.FIRST_PARAMETERS_ENCODED":
"params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328",
"compile_params.SECOND_PARAMETERS_ENCODED":
"params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432"
}
}
Each multiplier is a power of two — it's a left-shift dressed up as multiplication. The First Parameters amount packs four fields into a 64-bit integer:
| Field | Bits | Width | Multiplier |
|---|---|---|---|
PRINCIPAL_INTEREST_RATE | 0–15 | 16 | ×1 |
LOAN_EXPIRATION_TIME | 16–42 | 27 | ×65536 (2¹⁶) |
COLLATERAL_DECIMALS_MANTISSA | 43–46 | 4 | ×8796093022208 (2⁴³) |
PRINCIPAL_DECIMALS_MANTISSA | 47–50 | 4 | ×140737488355328 (2⁴⁷) |
The Second Parameters amount packs the two base amounts — each divided down by its decimal exponent so it fits in 25 bits:
| Field | Bits | Width | Multiplier |
|---|---|---|---|
COLLATERAL_AMOUNT / 10^collateral_decimals | 0–24 | 25 | ×1 |
PRINCIPAL_AMOUNT / 10^principal_decimals | 25–49 | 25 | ×33554432 (2²⁵) |
This is exactly the layout the covenants unpack. In
pre_lock.simf
and lending.simf, extract_lending_parameters masks and shifts these same bit
ranges back out, then validate_lending_params asserts they match the covenant's
own compile params:
#![allow(unused)] fn main() { let (interest_rate_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, 16, 0); // bits 0–15 let (loan_expiration_time_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, 27, shift); // bits 16–42 // …decimals… then from the second NFT, two 25-bit base amounts }
The manifest's packing and the covenant's unpacking are two halves of one wire
format — get the widths or multipliers out of sync and validate_lending_params
aborts the spend. That mutual dependence is the whole reason the encoding lives in
the example as a worked reference rather than something you'd reinvent.
Why pack at all? A covenant can only introspect what's in the transaction. By making the terms the NFTs' amounts, every spend that moves the NFTs carries the terms with it, and each covenant re-derives and re-checks them — no oracle, no side channel. The cost is the 64-bit budget you're packing into, which is why base amounts are stored with a separate decimals exponent.
Computing the covenant addresses
The borrower has to lock collateral to the pre_lock covenant — but pre_lock's
address depends on the lending covenant's hash, which depends on the principal
vault's hash, which depends on the Lender NFT's asset ID, which only exists
after the issuance inputs resolve. create_instance untangles this with a set
of tapleaf compute fields, each compiling a .simf to its script hash:
"create_instance": {
"class": "lending_contract",
"fields": {
"PRINCIPAL_OUTPUT_SCRIPT_HASH": {
"compute": "tapleaf", "simf": "./p2pk.simf",
"params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } }
},
"LENDER_PRINCIPAL_COV_HASH": {
"compute": "tapleaf", "simf": "./asset_auth.simf",
"params": {
"ASSET_ID": { "type": "liquid.asset_id", "value": "LENDER_NFT_ASSET_ID" },
"ASSET_AMOUNT": { "type": "u64", "value": "1" },
"WITH_ASSET_BURN": { "type": "bool", "value": "true" }
}
},
"LENDING_COV_HASH": {
"compute": "tapleaf", "simf": "./lending.simf",
"params": { "…": "…", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } }
},
"PRE_LOCK_COV_HASH": {
"compute": "tapleaf", "simf": "./pre_lock.simf",
"params": { "…": "…", "LENDING_COV_HASH": { "type": "bytes32", "value": "LENDING_COV_HASH" } }
},
"…": "…"
}
}
Read the value strings as references to other fields already computed. The
dependency order forms a chain, not a cycle:
p2pk.simf ───────────────► PRINCIPAL_OUTPUT_SCRIPT_HASH ─┐
asset_auth.simf ─────────► LENDER_PRINCIPAL_COV_HASH ─┐ │
▼ │
lending.simf ──────────► LENDING_COV_HASH ──┬──────────┤
│ │
script_auth.simf(LENDING) ► PARAMETERS_NFT_OUTPUT_SCRIPT_HASH,
BORROWER_NFT_OUTPUT_SCRIPT_HASH ─┐
▼
pre_lock.simf ───────────► PRE_LOCK_COV_HASH ◄───────────────┘
script_auth.simf(PRE_LOCK) ► PRELOCK_PARAMETERS_NFT_SCRIPT_HASH
The tool compiles them in dependency order: leaf programs first (p2pk,
asset_auth), then lending (which needs the vault hash), then the script_auth
wrappers keyed to LENDING_COV_HASH, and finally pre_lock (which needs all of
the above) and its own script_auth wrapper.
script_auth.simfcompiled twice. The same program appears as two distinct UTXO types —prelock_script_authandlending_script_auth— because it's compiled with two differentSCRIPT_HASHparams. One wraps the NFTs to thepre_lockcovenant during the offer; the other re-wraps them to thelendingcovenant once the loan is active. Same code, two addresses. The tapleaf compute recipe covers this pattern; covenant UTXO types covers why a.simfplus its params is an address.
One derived field: the interest amount
Most instance fields are either passthrough ("$params.X") or tapleaf hashes. One
is a plain arithmetic derived param:
"PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000"
The borrower never enters the interest amount — only the rate. The actual
satoshis owed are computed once, here, and stored in the instance so RepayLoan
can require exactly principal + interest later. (The lending covenant computes
the same figure on-chain in calculate_interest, so the two agree.)
What you end up with
After broadcast, create_instance writes
lending.instance.json next to the manifest, holding every field above: the four
asset IDs, the four covenant hashes, the packed parameter values, the interest
amount, and the borrower's key. This file is the deal. Every later
method — LockCollateral, SetupLending, RepayLoan, and the rest — is run with
--instance lending.instance.json so the wallet rebuilds the exact same covenant
addresses without re-entering anything. This is the full class / instance model
from Instance, state & constructors.
Run it
IssueUtilityNFTs needs four separate L-BTC UTXOs (one per issuance). Prepare
splits one into four; then run the constructor as the borrower:
# split one wallet UTXO into four for the four issuances
txw run examples/lending/txmanifest.json Prepare \
--wallet borrower.json
A testnet params file
IssueUtilityNFTs would otherwise prompt for every loan term. Drop a
params file next to
the manifest and the tool auto-discovers it — for testnet it looks for
txmanifest.testnet.json in examples/lending/. Here's a complete one for a
single-asset L-BTC loan (borrow testnet L-BTC against testnet L-BTC), so the
faucet can fund both the borrower and the
lender:
{
"COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"COLLATERAL_AMOUNT": "200000",
"COLLATERAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"PRINCIPAL_AMOUNT": "100000",
"PRINCIPAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_INTEREST_RATE": "1000",
"LOAN_EXPIRATION_TIME": "5000000"
}
That id is testnet L-BTC (the same asset the
faucet dispenses). The terms describe a
0.002 L-BTC collateral loan for 0.001 L-BTC principal at 10% interest
(1000 basis points). A few values are worth understanding rather than copying
blindly:
BORROWER_PUB_KEYis absent on purpose — it's awallet_keysource, so it auto-fills from the borrower wallet. The file only carries the terms you choose.*_DECIMALS_MANTISSAis0here, not8. Recall from bit-packing that each amount is stored asamount / 10^decimalsin a 25-bit base field, and the covenant rebuilds it asbase × 10^decimals. So the amount must be an exact multiple of10^decimalsand the base must fit in 25 bits (< ~33.5M). Withdecimals = 0the raw satoshi amount goes straight into the base field — perfect for sub-0.335-L-BTC testnet sums. For whole-coin amounts you'd raise the exponent (e.g.8, the manifest's default) so larger figures still fit the 25 bits — but then the amount must be a whole multiple of10^8(≥ 1 L-BTC), which the faucet won't cover.LOAN_EXPIRATION_TIMEis a block height — set it comfortably ahead of the current testnet tip (check an explorer;5000000is a placeholder). It only matters at liquidation; the constructor just records it.
With the file in place, the constructor reads every value from it — nothing to type:
# mint the NFTs, encode the terms, compute hashes, write the instance file
txw run examples/lending/txmanifest.json IssueUtilityNFTs \
--network testnet --wallet borrower.json
On success the four NFTs are in the borrower's wallet and
examples/lending/lending.instance.json exists. Sync, and confirm the NFTs:
txw sync --wallet borrower.json
txw get-balance --wallet borrower.json # four new single-asset balances
Inspect the instance file
Open examples/lending/lending.instance.json to see what the constructor
recorded — this is what every later method reads back:
{
"instance": {
"class": "lending_contract",
"fields": {
"BORROWER_NFT_ASSET_ID": "f94aff7f54bd4f4076a0aa07635264a32926966e119dc523ac86427d1f2239f7",
"BORROWER_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
"BORROWER_PUB_KEY": "0b9fa04ada4fcaa83b148ae76fee98fa1bd3a84a1eefe42295e0b98e1fbcac72",
"COLLATERAL_AMOUNT": "3452",
"COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"COLLATERAL_DECIMALS_MANTISSA": "0",
"FIRST_PARAMETERS_ENCODED": "327680000100",
"FIRST_PARAMETERS_NFT_ASSET_ID": "c59e58652d6a00ba53e2ef97556210b106aff996fd76a8de8d04bcbef6888775",
"LENDER_NFT_ASSET_ID": "0d51f8bcf2f6fe4e5c6ebc886a7cc87323e8599c5652ddb44bb6ac6c2e690d52",
"LENDER_PRINCIPAL_COV_HASH": "279a4424550ccc694525388d9c461166a1454defadff4204d1a0885bd5ca2b83",
"LENDING_COV_HASH": "135596e46be4b2a229ae25fea585b056e5b909e0aaee87fd6fc785c9201dd6cf",
"LOAN_EXPIRATION_TIME": "5000000",
"PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
"PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": "761c8268a5998d892c41df708dd64c18047b54750a760861af88e68336c1cfea",
"PRE_LOCK_COV_HASH": "4ac452b2c2c79b932d74f7fee114106328ce18d68ad92091ed345c6c22f59f07",
"PRINCIPAL_AMOUNT": "1000",
"PRINCIPAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
"PRINCIPAL_DECIMALS_MANTISSA": "0",
"PRINCIPAL_INTEREST_AMOUNT": "10",
"PRINCIPAL_INTEREST_RATE": "100",
"PRINCIPAL_OUTPUT_SCRIPT_HASH": "b4966bd0290ef509e7b1a98dd0b3fe54bd1860f9bb163bf3acb3d3a8401d41d4",
"SECOND_PARAMETERS_ENCODED": "33554435452",
"SECOND_PARAMETERS_NFT_ASSET_ID": "5a001563b2384198a09127c3ac6f36d0cc2a980000fd9915c3ca3cc2d6e2c136"
}
}
}
What to look at:
- The four
*_ASSET_IDfields are your freshly minted NFTs — derived from the issuance outpoints, so they're unique to this run. - The four covenant hashes (
PRE_LOCK_COV_HASH,LENDING_COV_HASH,LENDER_PRINCIPAL_COV_HASH, and the*_SCRIPT_HASHwrappers) are the addresses the next steps lock to — computed from those asset IDs and yourBORROWER_PUB_KEY. FIRST_PARAMETERS_ENCODED/SECOND_PARAMETERS_ENCODEDare the bit-packed terms — and also the amounts your two Parameter NFTs were issued with. You can check them by hand: with the decimal fields0,FIRST = INTEREST_RATE + EXPIRY × 65536 = 100 + 5000000 × 65536 = 327680000100, andSECOND = COLLATERAL + PRINCIPAL × 33554432 = 3452 + 1000 × 33554432 = 33554435452.
Yours will differ. Almost every value is derived, so a fresh run won't reproduce these — the asset IDs come from your outpoints and the hashes from your key. This sample also happens to come from a run with different terms than the params file above (a
3452-sat collateral loan at 1% interest), so the amounts won't match either. It's here to show the shape and what each field is for.
With the offer constructed, the borrower can put it on-chain: Opening the offer.
Opening the offer
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Phase 2 of the lending walkthrough. The borrower publishes the offer on-chain:
LockCollateralmoves the collateral and all four NFTs into covenant UTXOs, advancing the contract fromnfts_issuedtooffer_open.
After issuing the NFTs the borrower holds four tokens and
an instance file, but the collateral is still loose in their wallet. This phase
commits it — the protocol's first move of value into covenant addresses, and the
first use of the op_return destination and a pre-build validations check.
LockCollateral — publishing the offer
LockCollateral takes the collateral and all four NFTs from the borrower's wallet
and moves them into covenant addresses. The collateral goes into the pre_lock
UTXO; each NFT goes into a prelock_script_auth UTXO (the script_auth.simf
wrapper compiled to PRE_LOCK_COV_HASH):
"LockCollateral": {
"inputs": [
{ "id": "collateral_in", "utxo_source": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID",
"amount_sat": { "min_amount": "compile_params.COLLATERAL_AMOUNT" } },
{ "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "first_params_in", "utxo_source": "wallet", "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" },
{ "id": "second_params_in", "utxo_source": "wallet", "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "pre_lock_out", "destination": { "utxo_type": "pre_lock" }, "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
{ "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "first_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" },
{ "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" },
{ "id": "indexer_op_return", "destination": { "type": "op_return" },
"data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)" },
{ "id": "collateral_change", "destination": "change", "asset": "compile_params.COLLATERAL_ASSET_ID", "optional": true },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
],
"validations": [
{ "id": "collateral_amount_matches",
"rule": { "type": "arithmetic", "expr": "collateral_in.amount_sat == compile_params.COLLATERAL_AMOUNT" },
"error": { "code": "AMOUNT_MISMATCH", "message": "Collateral input amount does not match COLLATERAL_AMOUNT" } }
]
}
Two things worth pausing on:
- The
OP_RETURNadvertisement.indexer_op_returnwritesconcat(BORROWER_PUB_KEY, PRINCIPAL_ASSET_ID)into an unspendable output. It carries no value — it's a beacon so an indexer (or a prospective lender's wallet) can discover the open offer and the asset it wants by scanning for these markers. Outputs & destinations introduced theop_returndestination; here it's used for discovery rather than burning. - The collateral amount is checked twice. The
validationsblock asserts the input matchesCOLLATERAL_AMOUNTbefore building (a fast, friendly error), and thepre_lockcovenant re-checks it on-chain at spend time. Validations are a convenience; the covenant is the law.
After this broadcasts, the contract is in offer_open: collateral and NFTs sit in
covenant UTXOs that only the two pre_lock paths can move.
Run it
LockCollateral is run by the borrower, using the instance file written at
construction:
# --- Borrower puts the offer on-chain ---
txw run examples/lending/txmanifest.json LockCollateral \
--instance examples/lending/lending.instance.json --wallet borrower.json
txw sync --wallet borrower.json
With the collateral and NFTs locked behind pre_lock, the offer is live. Next, a
lender accepts it — or the borrower withdraws it:
Accepting or cancelling the offer.
Accepting or cancelling the offer
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Phase 3 of the lending walkthrough. The offer is open. A lender accepts it with
SetupLending, or the borrower withdraws it withCancelOffer— the two spending paths of the onepre_lockcovenant.
Opening the offer left the collateral and NFTs behind the
pre_lock covenant in state offer_open. That covenant can be spent two ways, and
this phase is where multiple spending paths
and the ScriptAuth wrapper carry real
weight.
Two paths, selected by a witness
pre_lock accepts two spending paths, and the manifest exposes each as its own
method: SetupLending takes the accept path, CancelOffer the cancel
path. A method picks its path with a PATH witness — Left(()) or Right(()) —
the simplicityhl selector from
Multiple spending paths. How each path
is enforced on-chain is out of scope here; what's new for the manifest are two
witness patterns this phase relies on, both visible in the JSON below:
SPEND_PATH— ataproot_leafwitness on every covenant input, naming which tapleaf is being spent. Its value comes from a…_leafformula (e.g.pre_lock_leaf).INPUT_SCRIPT_INDEX— asimplicityhlwitness on each NFT input. The NFTs live inprelock_script_authUTXOs (ascript_authcovenant type that must be co-spent with the collateral); the witness just tells that covenant which input the collateral is at — here,0.
SetupLending — the lender accepts (PATH::LEFT)
The lender spends the pre_lock collateral via the accept path (PATH = Left(())),
supplies the principal, and produces the active loan: collateral into the lending
covenant, principal to the borrower, the NFTs re-wrapped under the lending-phase
script_auth, and the Lender NFT to the lender's wallet.
The covenant requires those inputs and outputs in an exact order — and because
the principal is injected at output 1, the NFT outputs sit one slot below their
inputs. Rather than hope the builder lands on that layout, every input and output
declares an explicit required_index:
"SetupLending": {
"inputs": [
{ "id": "collateral_in", "utxo_source": { "utxo_type": "pre_lock" }, "required_index": 0,
"witnesses": { "PATH": { "type": "simplicityhl", "simplicity_type": "Either<()>", "value": "Left(())" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "pre_lock_leaf" } } } },
{ "id": "first_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 1,
"witnesses": { "INPUT_SCRIPT_INDEX": { "type": "simplicityhl", "simplicity_type": "u32", "value": "0" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "prelock_script_auth_leaf" } } } },
{ "id": "second_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 2, "…": "…" },
{ "id": "borrower_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 3, "…": "…" },
{ "id": "lender_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 4, "…": "…" },
{ "id": "principal_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "required_index": 5,
"amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT" } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true, "required_index": 6 }
],
"outputs": [
{ "id": "lending_collateral_out", "destination": { "utxo_type": "lending_collateral" }, "required_index": 0, "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
{ "id": "principal_to_borrower", "destination": { "utxo_type": "p2pk" }, "required_index": 1, "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "compile_params.PRINCIPAL_AMOUNT" },
{ "id": "first_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 2, "…": "…" },
{ "id": "second_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 3, "…": "…" },
{ "id": "borrower_nft_released", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 4, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_released", "destination": "wallet", "required_index": 5, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "principal_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true, "required_index": -2 },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true, "required_index": -1 }
]
}
Three details that make this work:
required_indexis the contract between manifest and covenant. The covenant readsoutput_amount(2)andoutput_script_hash(0)by literal index; if the builder placed them anywhere else the on-chain check fails. Negative indices (-1,-2) pin the optional change outputs to the end, so they never disturb the fixed prefix. This is the discipline recipe 6 flags as essential for introspecting covenants.- The Lender NFT goes to the lender's wallet (output 5,
destination: wallet), not back into a covenant. It's now the lender's bearer claim — they'll need it to liquidate or to drain the vault in settlement. - The NFTs re-wrap to a different
utxo_type. Outputs 2–4 targetlending_script_authinstead ofprelock_script_auth— the samescript_authprogram compiled toLENDING_COV_HASHrather thanPRE_LOCK_COV_HASH. A destination'sutxo_typeis all the manifest needs to move the tokens from the offer-phase covenant to the active-loan one.
CancelOffer — the borrower backs out (PATH::RIGHT)
If no lender accepts, the borrower reclaims the collateral and destroys the offer.
CancelOffer takes the cancel path (PATH = Right(())), which the covenant gates
with a borrower signature — so this method adds a SIGNATURE witness sourced from
BORROWER_PUB_KEY — and routes every NFT to an op_return to burn it. Here the
outputs line up one-to-one with the inputs (no principal is injected), so no
required_index is needed; the collateral returns to the borrower's wallet:
"CancelOffer": {
"inputs": [
{ "id": "pre_lock_in", "utxo_source": { "utxo_type": "pre_lock" },
"witnesses": { "PATH": { "type": "simplicityhl", "value": "Right(())" },
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "compile_params.BORROWER_PUB_KEY" } },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "pre_lock_leaf" } } } },
{ "id": "first_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" },
"…borrower & lender NFTs…",
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "pre_lock_in.amount_sat" },
{ "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" },
{ "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
{ "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
CancelOffer is unilateral in the lifecycle — the borrower needs no one's
cooperation, exactly as a trustless escape hatch should be. It mirrors the cold-key
break-out from the Last Will: a signature-gated path
that ends the contract.
Test the cancel path too. The happy flow runs
SetupLending;CancelOfferexercises the otherpre_lockbranch. If you only verify acceptance, the cancel path stays untested — run it against a fresh open offer separately.
Run it
Accepting is run by the lender; cancelling by the borrower. Both need the instance file the borrower wrote at construction.
For the lender to accept, they need a single UTXO holding exactly the principal.
PrepareLender carves one off any larger UTXO of the principal asset; then
SetupLending does the handshake:
# --- Lender accepts ---
txw run examples/lending/txmanifest.json PrepareLender --wallet lender.json
txw run examples/lending/txmanifest.json SetupLending \
--instance examples/lending/lending.instance.json \
--state examples/lending/lending.state.json \
--wallet lender.json
txw sync --wallet lender.json
To exercise the other branch instead, the borrower runs CancelOffer (any
time before a lender accepts) and gets the collateral back, burning the NFTs:
txw run examples/lending/txmanifest.json CancelOffer \
--instance examples/lending/lending.instance.json --wallet borrower.json
Both wallets need the instance file.
SetupLendingis run by the lender, but it still takes--instance lending.instance.json— the file the borrower produced at construction. The lender needs it to rebuild the covenant addresses and the packed terms. In a real deployment the borrower publishes the instance (or an indexer reconstructs it from the on-chain NFTs and theOP_RETURNbeacon); here, share the file between the two wallets.
Once SetupLending broadcasts, the loan is loan_active: the borrower has the
principal and the collateral is held by the lending covenant. On to
settling the loan.
Settling: repay, liquidate, withdraw
📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.
Phase 4 of the lending walkthrough. The loan is active. It ends one of two ways — the borrower repays and reclaims the collateral, or the lender liquidates it after the deadline — and the lender finally withdraws their principal plus interest from a vault.
By now the collateral sits behind the lending covenant, the borrower holds the
principal, and the lender holds the Lender NFT. This phase resolves the loan. It
reuses the two-path covenant shape from
accepting the offer, adds an
absolute timelock, and introduces the last piece — the
asset_auth.simf
principal vault.
A detour: ClaimLoanFunds
SetupLending delivered the principal to the borrower's p2pk address — a
covenant output, not a plain wallet UTXO. ClaimLoanFunds sweeps it into the
wallet so the borrower can actually use it. It's the
Hello World receive spend, unchanged: one
p2pk input, a Schnorr signature, one wallet output.
"ClaimLoanFunds": {
"inputs": [
{ "id": "principal_in", "utxo_source": { "utxo_type": "p2pk" },
"witnesses": {
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "p2pk_leaf" } },
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "$params.BORROWER_PUB_KEY" } } } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "principal_to_borrower", "destination": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
This is not a lifecycle transition — the loan is still loan_active whether or
not the borrower has swept the principal. It's housekeeping, included to show that
a covenant payout is just another UTXO you spend normally once it's yours.
Run it as the borrower. ClaimLoanFunds also needs --state so the tool knows
which p2pk UTXO to claim — the live one the state file recorded when
SetupLending paid the principal out:
txw run examples/lending/txmanifest.json ClaimLoanFunds \
--instance examples/lending/lending.instance.json \
--state examples/lending/lending.state.json \
--wallet borrower.json
txw sync --wallet borrower.json
The lending covenant: repay or liquidate
lending.simf
is the offer covenant's sibling — same Either<(), ()> PATH selector, different
two outcomes:
fn main() { match witness::PATH { Left(params: ()) => { loan_repayment_path(); }, // borrower repays Right(params: ()) => { loan_liquidation_path(); }, // lender seizes after expiry } }
PATH::LEFT is repayment — anyone can fund it, but it only succeeds if it routes
principal + interest to the lender's vault. PATH::RIGHT is liquidation — gated by
a timelock instead of a signature.
RepayLoan — borrower settles (PATH::LEFT)
The borrower returns principal plus interest and gets the collateral back. The
covenant computes the interest on-chain (calculate_interest, the same basis-point
math the instance stored as PRINCIPAL_INTEREST_AMOUNT) and demands the repayment
land in the lender's vault:
#![allow(unused)] fn main() { fn loan_repayment_path() { assert!(jet::eq_32(jet::current_index(), 0)); ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); // in0→out0 collateral back let first = ensure_input_and_output_assets_eq(1, 2, param::FIRST_PARAMETERS_NFT_ASSET_ID); let second = ensure_input_and_output_assets_eq(2, 3, param::SECOND_PARAMETERS_NFT_ASSET_ID); ensure_input_and_output_assets_with_amount_eq(3, 4, param::BORROWER_NFT_ASSET_ID, 1); // …unpack & validate terms… let owed: u64 = calculate_principal_with_interest(principal_amount, interest_rate); ensure_asset_with_amount(1, false, param::PRINCIPAL_ASSET_ID, owed); // out1 = principal+interest ensure_script_hash(1, false, param::LENDER_PRINCIPAL_COV_HASH); // …into the vault ensure_output_is_op_return(2); // burn the params + borrower NFT ensure_output_is_op_return(3); ensure_output_is_op_return(4); } }
Same off-by-one as SetupLending: the repayment is injected at output 1, shifting
the NFT outputs down. The collateral returns to the borrower's wallet (output 0),
the Parameter and Borrower NFTs are burned (the loan is over), and the
principal + interest goes to the vault — not directly to the lender. The manifest
mirrors that layout:
"RepayLoan": {
"inputs": [
{ "id": "lending_in", "utxo_source": { "utxo_type": "lending_collateral" },
"witnesses": { "PATH": { "type": "simplicityhl", "value": "Left(())" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lending_leaf" } } } },
{ "id": "first_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
{ "id": "second_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
{ "id": "borrower_nft_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
{ "id": "repayment_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID",
"amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
{ "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "compile_params.PRINCIPAL_ASSET_ID",
"amount_sat": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" },
{ "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" },
{ "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
{ "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "repayment_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
The repayment input requires PRINCIPAL_AMOUNT + PRINCIPAL_INTEREST_AMOUNT — the
derived interest the constructor
computed and stored. The Lender NFT isn't touched here; it's still in the lender's
wallet, waiting to unlock the vault. RepayLoan is the lifecycle's cooperative
happy path: state moves to repaid.
LiquidateAfterExpiry — lender seizes (PATH::RIGHT)
If the borrower never repays, the lender takes the collateral — but only after the deadline. The covenant enforces that with an absolute timelock:
#![allow(unused)] fn main() { fn loan_liquidation_path() { assert!(jet::eq_32(jet::current_index(), 0)); ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); let first = ensure_input_and_output_assets_eq(1, 1, param::FIRST_PARAMETERS_NFT_ASSET_ID); // identity mapping let second = ensure_input_and_output_assets_eq(2, 2, param::SECOND_PARAMETERS_NFT_ASSET_ID); ensure_input_and_output_assets_with_amount_eq(3, 3, param::LENDER_NFT_ASSET_ID, 1); // …unpack & validate terms… jet::check_lock_height(loan_expiration_time); // ← block height must be ≥ expiry ensure_output_is_op_return(1); ensure_output_is_op_return(2); ensure_output_is_op_return(3); } }
check_lock_height is the on-chain half of an nLockTime/CLTV timelock: the spend
is only valid once the chain reaches LOAN_EXPIRATION_TIME. Note the mapping is the
identity here (no payout injected) and the gating token is the Lender NFT,
which the lender brings from their wallet — there's no signature, holding the NFT
is the authorisation. The collateral lands in the lender's wallet; the NFTs burn.
The manifest also declares a friendly pre-build validations check so you get a
clean error instead of a rejected broadcast if you try too early:
"validations": [
{ "id": "expiry_reached",
"rule": { "type": "arithmetic", "expr": "current_block_height >= compile_params.LOAN_EXPIRATION_TIME" },
"error": { "code": "TIMELOCK_NOT_ELAPSED", "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." } }
]
This is the unilateral escape hatch for the lender, the mirror image of the
borrower's CancelOffer: state moves to liquidated. Compare the relative
timelock (check_lock_distance) in the Last Will —
that one counts blocks since the UTXO was created; this one names an absolute
height.
The principal vault: asset_auth.simf
Whichever way the loan settled honestly, RepayLoan parked the lender's money in a
lender_principal_vault UTXO rather than paying the lender directly. Why the extra
hop? Because at repayment time the transaction is driven by the borrower — they
shouldn't dictate the lender's receiving address, and the lender may be offline. The
vault holds the funds under a covenant only the Lender NFT holder can open:
#![allow(unused)] fn main() { fn auth_with_burn_check(input_asset_index: u32, output_asset_index: u32) { ensure_asset_and_amount_eq(input_asset_index, true, param::ASSET_ID, param::ASSET_AMOUNT); // LENDER_NFT, 1 ensure_asset_and_amount_eq(output_asset_index, false, param::ASSET_ID, param::ASSET_AMOUNT); match param::WITH_ASSET_BURN { true => ensure_output_is_op_return(output_asset_index), // and the NFT must be burned false => {}, } } }
Compiled with ASSET_ID = LENDER_NFT_ASSET_ID, ASSET_AMOUNT = 1, and
WITH_ASSET_BURN = true, it says: to move what's in this vault, you must spend the
Lender NFT as an input and burn it as an output. The NFT is a one-shot key.
ClaimPrincipalWithInterest — lender withdraws
The lender opens the vault by co-spending and burning their NFT, sending the principal + interest wherever they like:
"ClaimPrincipalWithInterest": {
"params": { "lender_destination": { "type": "address" } },
"inputs": [
{ "id": "vault_in", "utxo_source": { "utxo_type": "lender_principal_vault" },
"witnesses": {
"INPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_in)" },
"OUTPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_burned)" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lender_principal_vault_leaf" } } } },
{ "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "principal_interest_out", "destination": "params.lender_destination", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" },
{ "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
The index_of(...) formula is the key trick: the covenant needs to know which
input is the NFT and which output burns it, but those positions depend on how the
builder ordered the transaction. Rather than hard-code indices, the witnesses are
computed with index_of(id), which
resolves an input/output id to its final position — so the covenant's
INPUT_ASSET_INDEX / OUTPUT_ASSET_INDEX always point at the right slots. State
moves to settled, and the loan is fully wound down.
Why a vault instead of paying the lender directly — and "accumulation." The
asset_authpattern decouples when funds are paid in from when they're collected. A lender running many loans accrues a vault per loan, each unlocked by its own Lender NFT, and can sweep them on their own schedule from any wallet that holds the NFTs — without the borrowers needing the lender's addresses. It's a reusable building block: an asset-gated, burn-on-spend output.
Run it
The two settlements are run by different parties. The borrower repays —
returning principal + interest and reclaiming the collateral (you'll have swept the
principal with ClaimLoanFunds above):
# --- Borrower repays ---
txw run examples/lending/txmanifest.json RepayLoan \
--instance examples/lending/lending.instance.json --wallet borrower.json
Then the lender drains the vault:
# --- Lender collects principal + interest ---
txw run examples/lending/txmanifest.json ClaimPrincipalWithInterest \
--instance examples/lending/lending.instance.json --wallet lender.json
Or, if the borrower defaulted, the lender liquidates once the chain has passed
LOAN_EXPIRATION_TIME (before then, the TIMELOCK_NOT_ELAPSED validation stops
you):
txw run examples/lending/txmanifest.json LiquidateAfterExpiry \
--instance examples/lending/lending.instance.json --wallet lender.json
Remember to sync each wallet after a broadcast.
Liquidation needs the chain at the expiry height.
check_lock_heightis an absolute-height lock, so on testnet you either set a near-futureLOAN_EXPIRATION_TIMEat construction or wait for the height to arrive. Treat the liquidation path as illustrative until the chain catches up to the deadline you chose.
You've reached the end
That's the whole protocol: a borrower and a lender transacting a collateralised loan with no escrow, every rule — terms, amounts, timelock, payout routing — enforced by five small Simplicity covenants and four NFTs. Along the way you've now seen, working together, every concept the cookbook introduced one recipe at a time: covenant UTXO types, multiple spending paths, issuance & NFTs, formulas & derived params, hooks & tapleaf compute, and the class / instance model.
For the precise rules behind anything here, the authoritative reference is
Spec.md.
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 (compute: "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.