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 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: the generated field reference, type tables, the formula language grammar, the CLI command list, and a guide for implementing your own wallet.
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.
Every JSON snippet and command in this book is drawn from real files in the
reference wallet
(examples/p2pk/, examples/last_will/, examples/lending/) and its CLI —
nothing here is invented.
This book describes manifest format
0.2.0. That version string goes in every manifest asmanifest_version, and it is checked before anything else runs — a file written against a different revision is refused outright rather than half-understood.For the exhaustive field list — every type, default and constraint — see the manifest field reference, which is generated from the schema the reference wallet parses with.
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.
Manifest, instance, state
A live contract is three things, not one:
| What it holds | Lifetime | |
|---|---|---|
| The manifest | The protocol definition: contract templates, actions, inputs, outputs, witnesses. | Static — one file, shared by every deployment of the protocol. |
| The instance | The compile-time field values for one deployment: this borrower's pubkey, this loan's amount. | Fixed when the contract is instantiated, then read by every later action. |
| The state | The live on-chain UTXO set for that instance. | Changes with every broadcast. |
The manifest is the cookbook recipe; the instance is the specific ingredients you bought; the state is what's currently in the pot.
Only the manifest is a document you write and share. The other two are data a wallet accumulates by running actions, and it must keep them: without the instance it cannot rebuild the contract's addresses, and without the state it cannot find the UTXOs to spend.
Where that data lives is the wallet's business. The format says what has to persist between actions, never how to store it. The CLI in this book writes JSON files next to the manifest; a browser extension would use its own storage, and a hosted wallet a database. Nothing in a manifest depends on the choice.
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 fields, the on-chain states those contracts can create, and the valid transactions between them.
Anatomy of a manifest dissects each section in turn. But first, let's get the tooling ready.
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.2.0",
"protocol": "p2pk-simplicity",
"description": "Pay-to-public-key using a Simplicity checksig program on Liquid.",
"chain": "liquid",
"utxo_types": { ... },
"contract_templates": { ... },
"actions": { ... }
}
The envelope
| Field | Required | Purpose |
|---|---|---|
manifest_version | yes | Version of the tx-manifest format itself. Current: "0.2.0". |
protocol | yes | Kebab-case protocol identifier, e.g. "simplicity-lending". |
description | no | Free-text summary of the whole protocol. |
chain | no | "bitcoin", "liquid"/"elements", or "cross-chain". Defaults to "elements". |
simplicity_hl | no | SimplicityHL toolchain settings. See below. |
Two authoring keys may appear on any object at any depth and carry no protocol
meaning: $comment, for prose a tool must ignore, and $schema, an editor hint
pointing at the JSON Schema. Both are stripped before a manifest is interpreted.
simplicity_hl — how the covenants are compiled
The whole block is optional, and so is every field in it. Written out with its defaults, it says exactly what omitting it says:
"simplicity_hl": {
"debug_symbols": false,
"unstable_features": []
}
| Field | Type | Default | Effect |
|---|---|---|---|
debug_symbols | boolean | false | Compile the .simf programs with debug symbols. Changes every covenant address. |
unstable_features | array of strings | [] | Allow gated SimplicityHL syntax. Never changes an address. |
debug_symbols
Compiles contracts with debug_symbols enabled. Note that this will change the CMR and addresses generated. In general this should not be used (or set to false), but is present to interact with contracts that are already deployed with this set to true.
unstable_features
The manifest form of simc -Z <name>. If you are using a contract that requires
unstable features, you can enable them here.
The data sections
Three sections describe the protocol itself. Most files carry two of them, and
none is required — a manifest with an empty actions map is still a manifest.
Each gets a chapter of its own; this table is the map.
| Section | Holds | Covered in |
|---|---|---|
utxo_types | The covenant addresses this protocol locks value to — one named entry per on-chain state, each pointing at a .simf program | Covenant UTXO types |
actions | One transaction recipe each: which UTXOs to consume, what to create, what witnesses satisfy them | Hello World, Outputs & destinations, Witnesses |
contract_templates | A typed contract: named fields plus the actions that operate on them, with one instance per deployment | Instance, state & constructors |
Two shapes, and when the second one arrives
A protocol with a single contract type can put its actions straight under
top-level actions, and give each one the params it needs. That is the whole
file — no templates, no instance. Every recipe up to
Witnesses is written this way.
contract_templates earns its place the moment a value has to outlive a single
transaction. A loan's collateral amount is chosen once, baked into a covenant
address, and then read back by four later actions; it cannot be a param, because
a param is gone when the transaction is built. Declaring it as a template
field stores it in the instance, where later actions reach it as
instance.NAME. That is the whole distinction, and
Parameters draws it out properly.
The two are not alternatives to choose between up front. A file may carry
top-level actions and contract_templates at once — the lending example does,
using standalone actions for setup work that belongs to no instance.
Read a real one. The fastest way to see how these fit together is
examples/p2pk/txmanifest.jsonin the wallet repository — the smallest complete manifest — and thenexamples/lending_v3, which uses every section at once.
For the exhaustive list of fields each section accepts, with types and defaults, see the manifest field reference.
What runs it
A manifest is declarative: it says what a transaction must look like, never how
to build one. Turning it into a signed transaction is a wallet's job —
tx-manifest-wallet resolves the params, finds the inputs, compiles the
covenants, builds and signs a PSET, dry-runs the Simplicity programs, and
broadcasts. Each recipe introduces the parts it needs, so there is nothing to
learn here up front; the
wallet implementation guide has the full
sequence for when you want it.
With the skeleton in hand, let's write our first contract.
Installing the CLI
To run the recipes in this book you need a wallet that understands txmanifest.
An example wallet is provided at https://github.com/stringhandler/txmanifest-wallet
This page installs that CLI and points it at a network. Creating a wallet then gets you keys and coins.
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, and this book calls it txw
throughout — the two are interchangeable. There are two ways to get it.
The codespace installs nothing on your machine and is the fastest way to run a recipe. A local install is what you want if you would rather work in your own editor, or you are on arm64.
The codespace (nothing to install)
txw-codespace is a dev
container with the wallet already installed and nothing else. Click the badge in
its README, or from the repo page choose Code ▸ Codespaces ▸ Create codespace
on main. First creation takes a couple of minutes and ends with Ready. in the
log.
Then:
txw --help
Three things about it are worth knowing before you start:
-
work/is entirely gitignored. Put your manifests, wallets and state files there and nothing of yours can be committed by accident. Start every recipe withcd work. -
txwis a wrapper script, not a shell alias, so it works from scripts and editor tasks as well as from the prompt. -
The recipes that use bundled examples need one command first. The examples are not copied into the codespace, where they would drift from the format the installed wallet speaks. Fetch them on demand:
./scripts/fetch-examples.shThat puts a sparse clone in
work/txmanifest-wallet/, keeping upstream'sexamples/andschema/layout so the manifests' relative$schemareferences still resolve.
It runs on x86_64 only, because that is what the wallet publishes Linux binaries for. On an arm64 machine, use a local build.
A local install
A prebuilt binary is the simplest. Grab one from the
releases page —
Linux x86_64, macOS Apple Silicon, and Windows x86_64 are published — unpack it,
and put tx-manifest-wallet on your PATH:
curl -LO https://github.com/stringhandler/txmanifest-wallet/releases/download/v0.2.2/tx-manifest-wallet-v0.2.2-x86_64-unknown-linux-gnu.tar.gz
tar xzf tx-manifest-wallet-v0.2.2-x86_64-unknown-linux-gnu.tar.gz
sudo mv tx-manifest-wallet /usr/local/bin/
alias txw=tx-manifest-wallet
txw --help
Asset names are version-stamped, so check the releases page for the current
one. The macOS and Windows builds are
tx-manifest-wallet-<version>-aarch64-apple-darwin.tar.gz and
tx-manifest-wallet-<version>-x86_64-pc-windows-msvc.zip.
Or via asdf, which is how the codespace does it, and lets you keep several versions side by side (Linux x86_64 and macOS Apple Silicon; asdf is shell-based, so not Windows):
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
Or from source, which is the arm64 answer and the one to use if you want to
change the wallet itself. It is the txmanifest_wallet crate of a standard
Cargo workspace:
git clone https://github.com/stringhandler/txmanifest-wallet
cd txmanifest-wallet
cargo build --release # binary at ./target/release/tx-manifest-wallet
alias txw="$(pwd)/target/release/tx-manifest-wallet"
txw --help
A source clone also gives you examples/ directly, which is what the recipes'
bundled-example paths refer to.
Check the version speaks this format
This book is written against manifest format 0.2.0, and a wallet checks that
before it does anything else. Any 0.2.x release speaks it; a 0.1.x one will
refuse every manifest here.
txw --version
In the codespace the version is pinned in .tool-versions and can be changed
without a rebuild:
./scripts/set-wallet-version.sh latest
Throughout the book, commands are written as txw <subcommand>. Manifest paths
like examples/p2pk/txmanifest.json are relative to your current directory — run
them from a source clone, or from work/txmanifest-wallet/ in the codespace.
You do not need
simcinstalled. The SimplicityHL compiler is linked into the wallet as a library, sotxwcompiles the.simffiles a manifest points at in-process. Nothing shells out, and there is no separate toolchain to keep in step. Thesimc "=x.y.z";directive inside a.simfstill applies — it is enforced by that same linked compiler.
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.
With the CLI installed and pointed at testnet, the next step is a wallet: Creating a wallet.
Creating a wallet
tx-manifest-wallet needs keys to select UTXOs and to sign. This page creates a
wallet, funds it from the testnet faucet, and prepares the UTXOs an action needs.
This is a
txwpage, not a format page. How a wallet holds keys, where it keeps them, and what it calls the command to make one are all its own business. Nothing here appears in a manifest.
Make 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
⚠️ Use this wallet for testing only.
create-walletgenerates a fresh 12-word BIP39 seed phrase and writes it towallet.jsonin plaintext. Anyone who can read that file — a backup, a synced folder, a shared machine, a screenshot — controls every coin it holds, and there is no passphrase or encryption to stop them.This is a deliberate simplification in an example CLI, not an oversight to be worked around: it keeps the recipes runnable without a key-management detour. A real wallet encrypts key material at rest and keeps it off disk while unlocked.
So treat
wallet.jsonas disposable and keep it on testnet. Don't reuse a seed you care about, don't commit the file, and don't put mainnet funds behind it —--mainnet trueexists for completeness, not as a recommendation.
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: Splitting a UTXO, a warm-up with no covenant in it.
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.
You build it a piece at a time here, and run it in the next recipe.
Start with a skeleton
Create txmanifest.json with the envelope and one empty section:
{
"manifest_version": "0.2.0",
"protocol": "utxo-split",
"description": "Split one wallet UTXO into four equal wallet UTXOs.",
"chain": "liquid",
"actions": {}
}
Only manifest_version and protocol are required; description and chain
are here because they are useful. There is no utxo_types section, because
nothing is locked to a covenant, and no contract_templates, because nothing is
baked into a script. A manifest really can be this small.
Add the action shell
An action is one transaction recipe. Give it a name, a description, and the
values it will ask you for. Replace the empty actions with:
"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."
}
}
}
}
Split is the name you will type on the command line. Its one param,
amount_each, is a value you supply each time you run the action — the tool
prompts for it, and the description is the hint it shows you.
A param affects only the transaction being built. It never changes a script or an address, which is the distinction Parameters makes precise later.
An action with no inputs and no outputs is legal but builds nothing, so it
needs both.
Add the input
inputs lists the UTXOs the transaction consumes. This one needs a single
wallet UTXO, big enough to cover four shares. Add it inside "Split", after
params:
"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" }
}
]
Three things are being said here:
utxo_source: "wallet"— take any wallet-controlled UTXO. The alternative, which the next recipe uses, is to name a covenant to spend from.id— a handle for this input. Other parts of the action refer to it by this name, and errors quote it.amount_satas{ "min_amount": ... }— not a fixed amount, but a floor. The tool auto-selects a UTXO worth at leastparams.amount_each * 4. That expression is a formula;params.Xreads the param you just declared.
Add the outputs
outputs lists what the transaction creates, in the order they will appear.
Add it after inputs:
"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" }
]
Each split_n sends amount_each to destination: "wallet" — your own receive
address. They are written out four times rather than looped; a manifest has no
loops, and every output a transaction carries is written in the file.
That looks complete — and it will not run. Why not is the next recipe.
What you have so far
An envelope and one action. No utxo_types, because nothing is locked to a
covenant; no witnesses, because every input is an ordinary wallet UTXO the
wallet signs the usual way. Witnesses appear the moment you spend a covenant,
which is two recipes away.
The file so far
{
"manifest_version": "0.2.0",
"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" }
]
}
}
}
Try next
The manifest is written. In the next recipe you run it — and it fails, for a reason worth understanding before you write anything larger: Running it: change and fees.
Running it: change and fees
Problem. Run the manifest you just wrote, and account for every satoshi it moves — including the ones you did not think about.
You have a manifest (Splitting a UTXO) but you have not run anything yet. Doing so is how you meet the rule that governs every transaction a manifest builds: every output a transaction carries has to come from the manifest. The fee is the single exception.
Run it — and watch it fail
First, a funded wallet. If you have not done this yet, create one and fill it from the faucet (Creating a wallet):
txw info --wallet wallet.json # copy the receive address
Paste that address into the Liquid testnet faucet, request the coins, then pick them up:
txw sync --wallet wallet.json
Now run the action. It is worth checking the file parses first — validate
touches no network and no wallet:
txw validate txmanifest.json
txw run txmanifest.json Split \
--network testnet --wallet wallet.json
You will be prompted for amount_each. Give it something well under a quarter
of your balance, and the build stops:
[error] PSET build failed:
0: L-BTC does not balance: inputs exceed outputs by 600 sat, but the fee is 486 sat, leaving 114 sat unaccounted for.
This action does not permit L-BTC change, so there is nowhere for it to go. Either set "allow_change": "lbtc_only" on the action, declare a change output, or size the input to outputs + fee exactly.
Nothing was broadcast and nothing was spent — this failed while building the transaction, before any signature existed.
Where does the surplus go?
The input you selected is worth more than the four outputs plus the fee. That leftover has to go somewhere, and the engine will not decide for you.
It could send it to your change address — but you did not ask for that. It could add it to the fee — but then 114 satoshis leave your wallet to a miner, in an amount nobody wrote down. Every output a transaction carries has to come from the manifest, so rather than invent one, it stops and tells you the three ways out.
Fix it: declare a change output
The usual answer. Add a fifth output:
{ "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }
It differs from the four above in three ways, and all three matter:
destination: "change"sends to your change address rather than your receive address.- No
amount_sat. A change output takes whatever is left after the other outputs and the fee. It is the only kind of output that computes its own amount. optional: truelets the action drop it, because that remainder can be zero — and a zero-satoshi output is not something you can broadcast.
With it declared, your input only has to be big enough. Too small and you get the other error, which is the same arithmetic from the other side:
Insufficient L-BTC: have 41000 sat, need 42126 sat (outputs 40000 + fee 2126)
Or: let the engine add one
The other answer from the error message. Add allow_change to the action,
beside description:
"allow_change": "lbtc_only"
This permits the engine to return an L-BTC surplus without a declared output.
"lbtc_only" is the setting worth reaching for: a surplus in any other asset
is still an error, so a protocol token you failed to account for cannot quietly
walk out. The default is "none", which is why you saw the error at all.
Which one
Declare a change output when you want the change to be part of the transaction
you described — as Split does. Set allow_change when the surplus is an
artefact of fee estimation rather than something the action is about.
And sometimes neither is right. An action with no change output must size its
outputs to the input exactly, fee included, which sounds impractical until you
meet a recursive covenant: the Last Will refresh path
recreates its own covenant and may have only two outputs, so it computes its
amount as will_in.amount_sat - fee and declares no change at all.
Take the first fix — add change_out — and carry on.
Where is the fee?
Elements transactions pay the fee as a real output — an explicit L-BTC output with an empty script. So this transaction will have six outputs, not five.
The fee is the one output the engine appends on its own, because its amount is only known once the transaction's size is, and that is after everything else is decided. It is always last.
That exception is worth remembering when you read a covenant. A program that
asserts num_outputs == 2 against a manifest declaring a single output is not
wrong — it is counting the fee.
If you want the fee visible in the manifest anyway, an output can declare
"destination": { "type": "fee" }. It produces nothing on its own; it is a note to the reader that the fee leg is expected here. None of the examples use it, but a covenant that counts outputs is the case where it earns its place.
Run it again
txw run txmanifest.json Split \
--network testnet --wallet wallet.json
This time it selects an input, builds four outputs plus change, signs, and broadcasts. Afterwards your wallet holds four fresh UTXOs:
txw sync --wallet wallet.json
txw get-balance --wallet wallet.json
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. You have just written the long way round, which is the point — the next recipe does something no built-in command can.
The finished file
The complete file
{
"manifest_version": "0.2.0",
"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 }
]
}
}
}
Try next
You have written a manifest and broadcast a transaction from it, and you know where every satoshi went. 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.2.0",
"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 four fields at the top, 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 contract_templates block, because the recipient's key is a runtime
parameter of the action rather than a value fixed at deploy time.
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"
}
},
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
}
]
}
}
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 Pay half of
txmanifest.json
under examples/p2pk/. That file also carries the Receive action, which
Part 2 builds.
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 (Creating a wallet),
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 contract's state, 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 contract's state
After a successful Pay, the new covenant output is recorded in the contract's
state — the set of UTXOs this deployment currently owns. This is what the CLI
stored:
{
"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.
Any action that spends a covenant needs this state; without it a wallet has no
idea the UTXO exists. Carrying it from one action to the next is the wallet's
job, and every wallet does it differently — this CLI keeps it in a file you name
with --state, which the next lesson uses. See the
CLI reference for how it
handles those files.
No instance
A contract accumulates up to two kinds of data: an instance (compile-time field values) and its state (live UTXOs). This lesson produced only state — there is no instance at all.
Why? An instance exists to persist a contract template's fields, and this
contract declares no contract_templates at all. The recipient's key is an
action param, supplied fresh at run time and never stored. With no template
fields to record, there is nothing for an instance to hold. Instances first
appear once we introduce contract templates 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 shows how a param
can fill itself in: Parameters.
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 contract's state. This lesson adds the
Receive action, which spends it. Three new things have to come together:
- The contract's state locates the UTXO. We never type a txid — the wallet
already knows the live
p2pk_outputentry, becausePayrecorded it. - 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, so the contract's state holds 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 in the contract state 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 contract's state, not your wallet. p2pk_in's
utxo_source is { "utxo_type": "p2pk_output" }. Unlike a "wallet" input,
this tells the tool to look in the contract's state for a live UTXO of that
type — the very one Pay recorded. With no state to look in, the input cannot
resolve.
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 contract's state
from Part 1, run:
txw run examples/p2pk/txmanifest.json Receive \
--network testnet --wallet wallet.json \
--state examples/p2pk/txmanifest.state.1.json
This CLI takes the state as a file, and does not go looking for one — substitute
the path your Pay actually wrote. Another wallet would find its own stored
state without being told.
run prompts for pubkey (use the same key as in Pay), finds the
p2pk_output, 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
records the new state.
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 after spending
A successful Receive consumes the covenant UTXO, so it is removed from the
contract's state. 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 that drive these actions, and at which of them change the covenant's address: Parameters.
Parameters
Problem. Understand when a value belongs to a contract template's
fieldsversus an action'sparams, and how a value gets filled in without prompting.
The Pay action from recipe 1 takes one value from
you each time it runs, and bakes another into the covenant address forever. This
recipe pins down the difference.
Two kinds of parameters
This trips up everyone at first, so it's worth being precise:
template fields | 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 | nowhere; supplied at run time |
| Referenced as | instance.NAME | params.NAME |
| 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 belongs in a template's fields. If it only affects which inputs and
outputs this particular transaction picks, it's an action param.
A single-type contract that never needs an instance can skip
contract_templates altogether and declare everything as action params — which
is what the early recipes in this book do.
Params that fill themselves in: compute
A param that declares a compute block is never prompted for. The simplest form
is a bare formula string; the structured forms cover what an expression cannot
say:
"params": {
"BORROWER_PUB_KEY": {
"type": "pubkey",
"description": "Borrower's signing key.",
"compute": { "type": "wallet", "wallet": "key" }
},
"PRINCIPAL_INTEREST_AMOUNT": {
"type": "u64",
"compute": "instance.PRINCIPAL_AMOUNT * instance.INTEREST_RATE / 10000"
}
}
compute.type | Produces |
|---|---|
(bare string) or expr | The value of a formula |
wallet | A value from the executing wallet — see below |
script_hash | sha256(scriptPubKey) of an address |
tapleaf | A covenant script hash, by compiling a .simf |
simf_fn | The return value of a named function in a .simf |
hook | Nothing here — a hook sets this param later in the run |
The wallet variant takes a second key naming which wallet-derived value it wants:
compute.wallet | Resolves to |
|---|---|
"key" | The wallet's 32-byte x-only BIP340 pubkey |
"script_hash" | sha256(scriptPubKey) of the wallet's index-0 explicit output |
"address" | The explicit address matching "script_hash" — the two are a pair |
Declaring { "type": "hook" } looks pointless but is not: it is how a param that
a hook will set gets an identifier. Without the declaration, a hook writing to
params.SOMETHING would be inventing a name nothing checks.
If a param has no compute, the tool prompts you for it interactively (or you
supply it via --params, below). In recipe 1, PUBKEY has no compute, so
Pay prompts you for it.
Where a manifest's guarantees come from
There is no block for business rules — no place to write "amount must be greater than zero" and have the wallet check it. That is deliberate: a rule a wallet enforces is a rule a different wallet can skip. Guarantees come from three places instead, in descending order of strength:
- The covenant. Anything that actually protects value belongs in the
.simf, where the chain enforces it and no wallet can skip it. If a value matters, make the program check it. allow_change. An action defaults to"none", so a surplus that no declared output absorbs is an error rather than a silent change output moving value somewhere you never wrote down.- Parsing. Unknown and misspelled fields are hard errors, and
manifest_versionis checked before anything else runs, so a file written for a different format revision fails immediately rather than half-working.
Run it
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
Keys must match the action's param names exactly — pubkey, not PUBKEY.
The CLI also auto-discovers a per-network param file sitting next to the
manifest, named <manifest-stem>.<network>.json — so txmanifest.testnet.json
beside txmanifest.json is picked up whenever you pass --network testnet. An
explicit --params file is loaded after it and wins on any key they share.
Try next
That covers the values going into an action. Next we get precise about what comes out: the 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 | no | Amount in satoshis — a literal or a formula. Omit it for a change destination, which auto-computes the remainder. |
asset | no | Asset ID — "lbtc", a 64-char hex ID, or a reference. Omit only where the destination implies it. |
description | no | Human-readable purpose. |
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. |
blinding | no | Pin this output's asset_bf / value_bf instead of letting the builder choose. |
data | no | OP_RETURN payload — only valid with the op_return destination. |
ui | no | Clear-signing label and role for this leg. |
required_index | no | Documents the index the covenant expects. Not enforced — see below. |
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": "instance.PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" }, "amount_sat": 1, "asset": "instance.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(instance.BORROWER_PUB_KEY, instance.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.
datatakes either theconcat(...)string above, or an object of typed parts when the exact byte layout matters:"data": { "parts": [ { "type": "bytes32", "value": "instance.BORROWER_PUB_KEY" }, { "type": "u64", "value": "instance.PRINCIPAL_AMOUNT" } ] }Use the object form when a field's width or endianness has to be pinned; the string form is shorthand for concatenating 32-byte references.
-
Burning a token. Spend an NFT into
OP_RETURNto destroy it — the lending protocol burns auth NFTs this way to prevent reuse.
burn
{ "id": "nft_burned", "destination": { "type": "burn" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }
The engine treats burn exactly as op_return: a bare OP_RETURN unless the
output carries a data field, in which case the bytes are embedded. The two are
interchangeable, so pick the one that says what you mean — burn when the point
is destroying a token, op_return when the point is publishing bytes.
fee
{ "id": "network_fee", "destination": { "type": "fee" } }
A declaration, not an output. Elements pays the fee as a real explicit L-BTC output, but the engine always appends that itself — its amount is only known once the transaction's size is. This destination produces nothing of its own.
Declare it when a covenant counts outputs and you want the manifest to show the leg the program is counting. Otherwise leave it out; every example does.
Surplus, change and fees
An output with "destination": "change" is a declared change output, and takes
whatever is left after the other outputs and the fee. An action can also permit
the engine to add a change output it never declared, with allow_change:
"none" (the default), "lbtc_only", or "any".
Both, and the arithmetic behind them, are covered where you first meet them: Running it: change and fees.
How confidentiality is decided
On Liquid, outputs can be blinded (amount and asset hidden). Blinding is decided per output:
- The output's own
confidentialfield, if present. - Otherwise the chain default:
falsefor Bitcoin,truefor Liquid/Elements.
There is no file-level or per-utxo_type default to fall back on, and that is
the right granularity: blinding is a property of an output, not of an address.
One covenant address can hold a blinded reissuance token beside an explicit
collateral UTXO, so only the output itself can answer the question.
An output may also carry a blinding block pinning the asset_bf and value_bf
scalars the builder would otherwise choose at random. That matters when a
covenant verifies its own UTXOs as Pedersen commitments and so has to know the
factors in advance.
OP_RETURN outputs are always explicit — there is nothing to hide in a
provably unspendable output.
Blinding a covenant output is allowed, and usually wrong. Setting
confidential: trueon autxo_typedestination does work: the output is blinded with the wallet's change blinding key, and a later action can spend it by declaring the factors it was created with. But a Simplicity covenant reads explicit amounts and asset IDs through jets likecurrent_amountandcurrent_asset, and cannot read a Pedersen commitment. So blind a covenant output only when the program never introspects the value — and when it does need to, pin the factors withblindingso the spend can reconstruct the prevout.
Output order, and required_index
Covenants that introspect the transaction often require outputs in an exact order
("collateral at output 0, principal at output 1"). What puts an output at a
given index is the order you declare it in — outputs are built in the order
they appear in the outputs array. If the covenant wants the collateral first,
write the collateral output first.
required_index records that expectation:
{ "id": "lending_collateral_out", "required_index": 0, "destination": { "utxo_type": "lending_collateral" }, ... }
{ "id": "principal_to_borrower", "required_index": 1, "destination": "params.borrower_address", ... }
required_indexis documentation. Nothing checks it. The tool parses the field and never compares it against where the output actually landed, so a value that disagrees with the declaration order is silently ignored and the covenant sees the declared order. Treat it as a comment that tells the next reader — and you, six months later — which positions the program depends on. The thing that has to be right is the order of the array.The same field exists on inputs, with the same caveat. Input order likewise comes from the
inputsarray.
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 forms a witness takes, and the rule that every witness the
program declares must be named.
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.
The map and the program must agree exactly, in both directions. Every witness the program declares has to appear in the map, and every entry in the map has to name a witness the program declares. A name the program doesn't have is an error ("'X' is declared here but the program has no such witness"); so is a witness you left out. Nothing is inferred from an omission — see Witnesses on the path you didn't take.
taproot_leafis the single exception, because it is not a program witness at all. More on that below.
The forms a witness takes
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 contract-template field (instance.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.
taproot_leaf — which leaf to spend
Not a program witness at all. A covenant whose tap tree has more than one leaf needs to say which one this spend uses, and that selection is made outside the program:
"SPEND_PATH": {
"type": "taproot_leaf",
"source": { "type": "formula", "expr": "pre_lock_leaf" }
}
Because the program never reads it, it is exempt from the agreement rule above:
it does not have to correspond to a witness:: name, and it is skipped when the
map is matched against the program's witness list. Do not confuse it with a
PATH selector — PATH chooses a branch inside one program; SPEND_PATH
chooses which program runs.
"unused" — a witness this path doesn't read
The bare string, in place of an object:
"HOT_SIG": "unused"
Covered in full in the next section.
Witnesses on the path you didn't take
A program declares every witness it could read, but a single spend travels one path. The other branches still need values — before Simplicity prunes them, every witness node needs some concrete bit-vector — so those slots have to be filled with zeros.
You have to ask for that zero explicitly, by name. Write the string
"unused" where the object would go:
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
"COLD_SIG": { "type": "Signature", "sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "instance.COLD_PUB_KEY" } },
"HOT_SIG": "unused",
"INHERITOR_SIG": "unused"
}
That is the last_will cold-key spend: four witnesses declared, one selector, one
real signature, two zeros — and all four named.
Why name them at all, rather than let the tool fill in the blanks? Because then a misspelled
HOT_SIGand a deliberate omission would be the same thing. Both would leave the slot at zero, the transaction would build, and the mistake would surface much later as a covenant that simply does not satisfy — with nothing pointing at the cause. Naming every witness costs one line each and lets the tool check your map against the.simfbefore it builds anything.
The clearest illustration is one covenant spent two ways. The lending pre_lock
program declares PATH and SIGNATURE. SetupLending takes the left path,
which reads no signature:
"PATH": { "type": "simplicityhl", "value": "Left(())" },
"SIGNATURE": "unused"
CancelOffer takes the right path, which does:
"PATH": { "type": "simplicityhl", "value": "Right(())" },
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "instance.BORROWER_PUB_KEY" } }
Same two names in both. Only the values differ.
Our single-path p2pk.simf declares just SIGNATURE and always reads it, so
nothing is ever "unused" there.
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.
When it goes wrong
The four legal forms are "type": "simplicityhl", "type": "Signature",
"type": "taproot_leaf", and the bare string "unused". Anything else is
rejected by name, so the common mistakes report themselves:
| Message | Cause |
|---|---|
'X' is declared here but the program has no such witness | A name in the map the .simf doesn't declare — usually a typo, or a witness renamed in the program |
| missing / nothing supplied for a declared witness | A witness left out entirely. Add it, with "unused" if this path doesn't read it |
'X' has unrecognized type 'Y' | A type outside the four above |
'X' is a Signature witness that was never signed | No signer was available — the source key isn't one your wallet can derive |
That last one is worth recognising: it means the entry was still a Signature
when the tool went to build the witness stack, i.e. the key lookup failed. It is
not a problem with the program.
See also
- 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 — but all four witnesses must
still be named on the input, the two you don't read as
"unused".
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
contract template models. We define a
last_will_contract contract template whose fields are the three keys, and
whose actions are the four transactions. A constructor (Fund) records
the keys in an instance the first time you set the will up; the spend
actions read them back.
"contract_templates": {
"last_will_contract": {
"fields": {
"INHERITOR_PUB_KEY": { "type": "pubkey" },
"HOT_PUB_KEY": { "type": "pubkey" },
"COLD_PUB_KEY": { "type": "pubkey" },
"INHERIT_BLOCKS": { "type": "u16" }
},
"actions": { "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 param:: names 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 creates the instance. 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.
Carrying that block is what makes Fund a constructor — there is no separate
flag to set:
"Fund": {
"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": {
"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 Creating a wallet);
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
contract_templatesand so no instance at all — the keys here are a template's fields, persisted at construction. Every later spend reads them from the instance, so you only enter the keys once. This is the full template / instance model from Instance, state & constructors.
Each spend reads the instance
Every spend is an action 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 instance.* — the fields loaded back from the instance,
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": "instance.COLD_PUB_KEY" }
},
"HOT_SIG": "unused",
"INHERITOR_SIG": "unused"
}
},
{ "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 — and the two signatures this path never reads, named as
"unused" because the program declares them. Inherit is the same shape with
SPEND_PATH = Left(()), a real INHERITOR_SIG, and COLD_SIG / HOT_SIG
unused.
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": "instance.HOT_PUB_KEY" } },
"COLD_SIG": "unused",
"INHERITOR_SIG": "unused"
}
}
],
"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:
- The re-locked output is declared first.
recursive_covenantchecksoutput_script_hash(0), sowill_againhas to be output 0 — and it is, because it is the first entry in theoutputsarray. Therequired_index: 0beside it records that dependency for a reader; it is not what enforces it. - 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 compute; 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 \
--instance examples/last_will/txmanifest.instance.1.json \
--state examples/last_will/txmanifest.state.1.json
Both flags are needed, and this CLI does not go looking for either: --instance
supplies the three keys the covenant address is rebuilt from, and --state
supplies the UTXO to spend. Substitute the paths Fund actually wrote.
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.
Inherit and the relative timelock
inherit_spend calls jet::check_lock_distance, which reads the spending
input's nSequence. A transaction that doesn't set it fails the check no
matter how old the UTXO is, so the input has to ask for the lock explicitly:
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"sequence": { "relative_blocks": "instance.INHERIT_BLOCKS" },
"witnesses": { "…": "…" }
}
sequence takes one of three forms:
| Form | Meaning |
|---|---|
{ "relative_blocks": <expr> } | BIP68 block-based lock, at most 65535 blocks |
{ "relative_seconds": <expr> } | time-based lock, rounded up to 512-second units |
| a bare integer or expression | a raw nSequence value |
Omit it and the input stays at Sequence::MAX, which disables relative
locktime — the default, and the reason every other action here leaves it out.
Setting it does not make the UTXO old enough; it declares the age the spend
claims, and the network rejects the transaction until the UTXO actually reaches
it. Inherit is also signed by the heir's key rather than yours, so running
it needs the heir's wallet.
Caveat on
Refresh. It relies 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.- Covenant address determinism: same
.simf+ same params + samedebug_symbols→ same address, always. TheP2TR(NUMS, tapbranch(...))construction.paramson the type andargsat each site: closing a UTXO type's scope so its address derivation reads only what it declares.extra_leavesfor appending additional taproot leaves.
In the meantime, UtxoType and UtxoScript
in the field reference list every key, and
examples/last_will
is the smallest covenant with more than one leaf.
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.- Declaring inputs in the order the covenant's introspection expects.
- Cooperative vs unilateral paths, and how each is enforced by the covenant.
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, a param'scompute, and hooksetvalues. (Not witnesses — a witness takes a literal value, not an expression.)- Operators (
+ - * /, comparisons,&& || !) and references (instance.X,params.X,input_id.amount_sat,input_id.asset,input_id.present).- Functions:
pow(base, exp), andconcat(...)forOP_RETURNdataonly.- The special
feevalue used in change and re-lock formulas.- Computed params (
"compute": "..."): interest =PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000, and params derived from issuance outpoints.
In the meantime the formula language reference
has the operators, the references, and the $ prefix rule.
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 | new only | How many reissuance tokens to mint. 0 fixes the supply forever — nobody can ever issue more. |
entropy | reissue only | The issuance entropy of the original mint. See below. |
issued_asset | no | On a reissue, the asset ID you expect. A check, not an input. |
Either amount may be 0: an issuance of reissuance tokens alone, or of a
fixed-supply asset with no reissuance rights.
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": { "instance.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,
instance.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.
Minting more later: reissue
Issuing more of an existing asset means spending its reissuance token, and that
needs a value you can only capture at the original mint: the issuance
entropy, fast_merkle_root([sha256d(defining outpoint), contract_hash]). The
asset ID is derived from it.
It cannot be recovered from the chain. The reissuance-token UTXO carries no
trace of the outpoint that created it, so if the constructor doesn't record the
entropy, the asset can never be reissued. Capture it in create_instance, where
$inputs.<input_id>.issuance_entropy reaches it:
"create_instance": {
"fields": {
"TOKEN_ISSUANCE_ENTROPY": "$inputs.defining_in.issuance_entropy",
"TOKEN_ASSET": "$inputs.defining_in.issued_asset"
}
}
The reissuing action then hands it back:
{
"id": "reissue_in",
"utxo_source": "wallet",
"asset": "instance.TOKEN_REISSUANCE_TOKEN",
"issuance": {
"kind": "reissue",
"asset_amount_sat": "params.EXTRA_UNITS",
"entropy": "instance.TOKEN_ISSUANCE_ENTROPY",
"issued_asset": "instance.TOKEN_ASSET"
}
}
issued_asset is optional and is worth supplying. The engine re-derives the
asset ID from the entropy and refuses to build if the two disagree — and since an
entropy is opaque, and block explorers print it in the reverse byte order from
the one used here, a transposed value would otherwise build a perfectly
broadcastable transaction that reissues the wrong asset.
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:
instance.Xandparams.X.- The tapleaf compute spec (
"type": "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.
In the meantime, HookBlock and ParamCompute
in the field reference describe both, and
Issuing the NFTs works a real
tapleaf-compute chain end to end.
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 template's field values and tracking the live UTXO set across transactions.
🚧 This recipe is a stub. Outline of what it will cover:
- Manifest, instance and state in practice, and what a wallet has to keep.
- Contract templates: grouping actions under a typed contract with
fields, and theinstance.NAMEnamespace they populate.- Constructors: an action carrying a
create_instanceblock is a constructor — there is no separate flag — and recording the instance ({ "instance": { "template", "fields" } }) with resolved field values.- The state: 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).
In the meantime, a Last Will covenant builds a template,
a constructor and an instance from scratch, and
ContractTemplate and InstanceCreate
in the field reference list the keys.
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 template / 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 contract template, and its
actions walk an instance through a sequence of states:
| State | Reached by | Meaning |
|---|---|---|
nfts_issued | IssueUtilityNFTs | The four NFTs exist; terms are encoded. |
offer_open | LockCollateral | Collateral and NFTs sit behind pre_lock. |
cancelled | CancelOffer | The borrower withdrew before a lender took it. |
loan_active | SetupLending | A lender funded the offer. |
repaid | RepayLoan | The borrower paid principal plus interest. |
liquidated | LiquidateAfterExpiry | The deadline passed; the lender took the collateral. |
settled | ClaimPrincipalWithInterest | The lender drew the repayment from the vault. |
These state names are ours, not the format's. A manifest has nowhere to declare a state machine — no
stateslist, nofrom/toon an action. The table above is prose, written to make this chapter readable.What actually constrains the order is the chain. Each action's inputs name UTXO types that only the previous action creates, so
SetupLendingcannot run beforeLockCollateralhas produced apre_lockoutput to spend. Read the sequence off the covenants, which enforce it, rather than off a table, which cannot.
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
Two of those arrows are unilateral 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. RepayLoan is the cooperative happy path both
sides want. Nothing in the manifest labels them as such; what makes an exit
unilateral is that its covenant path needs only one party's key or NFT, which you
can read off the action's witnesses.
How this chapter is organised
The walkthrough follows those states 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. - 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 and made a wallet 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 contract templates, their fields and actions; 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 action can read it back (recipe 10).
The constructor and its terms
IssueUtilityNFTs is the template's constructor: it carries a create_instance
block, and that is what makes it one. Its params are the loan terms the
borrower chooses:
"IssueUtilityNFTs": {
"params": {
"BORROWER_PUB_KEY": { "type": "pubkey", "compute": { "type": "wallet", "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 compute 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": { "instance.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": "instance.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 },
"on_resolved": { "set": { "instance.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } }
}
All four asset IDs land in instance.* 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": {
"instance.FIRST_PARAMETERS_ENCODED":
"params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328",
"instance.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": {
"fields": {
"PRINCIPAL_OUTPUT_SCRIPT_HASH": {
"type": "tapleaf", "simf": "./p2pk.simf",
"params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } }
},
"LENDER_PRINCIPAL_COV_HASH": {
"type": "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": {
"type": "tapleaf", "simf": "./lending.simf",
"params": { "…": "…", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } }
},
"PRE_LOCK_COV_HASH": {
"type": "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
action — 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 template / 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 a walletcompute, 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 action reads back:
{
"instance": {
"template": "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.
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": "instance.COLLATERAL_ASSET_ID",
"amount_sat": { "min_amount": "instance.COLLATERAL_AMOUNT" } },
{ "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_in", "utxo_source": "wallet", "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "first_params_in", "utxo_source": "wallet", "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" },
{ "id": "second_params_in", "utxo_source": "wallet", "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "pre_lock_out", "destination": { "utxo_type": "pre_lock" }, "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" },
{ "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "first_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" },
{ "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" },
{ "id": "indexer_op_return", "destination": { "type": "op_return" },
"data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)" },
{ "id": "collateral_change", "destination": "change", "asset": "instance.COLLATERAL_ASSET_ID", "optional": true },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
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 on-chain, and only on-chain. The
pre_lockcovenant re-derivesCOLLATERAL_AMOUNTand rejects a spend that does not match. Nothing checks it before the build, so a mis-sized input costs you a failed dry-run rather than a friendly prompt — the covenant is the law, and it is also the only check.
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
action: SetupLending takes the accept path, CancelOffer the cancel
path. An action 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": "instance.PRINCIPAL_ASSET_ID", "required_index": 5,
"amount_sat": { "min_amount": "instance.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": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" },
{ "id": "principal_to_borrower", "destination": { "utxo_type": "p2pk" }, "required_index": 1, "asset": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "instance.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": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_released", "destination": "wallet", "required_index": 5, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "principal_change", "destination": "change", "asset": "instance.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 action 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": "instance.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" }, "…": "…" },
{ "id": "second_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" },
{ "id": "borrower_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" },
{ "id": "lender_nft_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
],
"outputs": [
{ "id": "collateral_returned", "destination": "wallet", "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "pre_lock_in.amount_sat" },
{ "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" },
{ "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
{ "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.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": "instance.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": "instance.PRINCIPAL_ASSET_ID",
"amount_sat": { "min_amount": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" } },
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "collateral_returned", "destination": "wallet", "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" },
{ "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "instance.PRINCIPAL_ASSET_ID",
"amount_sat": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" },
{ "id": "first_params_burned", "destination": { "type": "op_return" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", "amount_sat": "first_params_in.amount_sat" },
{ "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
{ "id": "borrower_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "repayment_change", "destination": "change", "asset": "instance.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.
Liquidating early is refused by the chain, not by the wallet: check_lock_height
in the covenant is what enforces the deadline, so trying before
LOAN_EXPIRATION_TIME costs you a failed dry-run rather than a friendly warning.
Nothing in the manifest re-states the deadline, and nothing should — a second
copy could disagree with the covenant, and the covenant is the one that decides.
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": "simplicityhl", "simplicity_type": "u32", "value": "1" },
"OUTPUT_ASSET_INDEX": { "type": "simplicityhl", "simplicity_type": "u32", "value": "1" },
"SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lender_principal_vault_leaf" } } } },
{ "id": "lender_nft_in", "utxo_source": "wallet", "asset": "instance.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": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" },
{ "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
The two index witnesses are what let the covenant find its NFT. asset_auth
checks that a specific asset is present at a specific input and burned at a
specific output, so it has to be told which positions those are —
INPUT_ASSET_INDEX is 1 because lender_nft_in is the second input, and
OUTPUT_ASSET_INDEX is 1 because lender_nft_burned is the second output.
These are literals, and that couples the manifest to its own layout. There is no formula that resolves an
idto its final position; the indices are written by hand, asu32values, against the order the inputs and outputs are declared in. Reorder either list and you must renumber these witnesses, or the covenant looks at the wrong slot and the spend fails the dry-run. Count from the declarations above before changing them.
With the vault swept, 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 template / instance model.
For the precise rules behind anything here, see the manifest field reference.
Manifest field reference
Every field the manifest format defines, generated from
schema/txmanifest.schema.json
— the schema the reference wallet emits from the types it parses with, so this
page cannot drift from the engine. See schema/README.md
for the exact revision it was taken from.
A field not listed here is a parse error, not an ignored key. Two
exceptions apply everywhere and are not repeated per type: $comment
(documentation a tool must ignore) and $schema (an editor hint), both legal
on any object at any depth.
For what the type strings mean — u64, pubkey, liquid.asset_id — see
Field types.
Manifest (root)
| Field | Type | Required | Default |
|---|---|---|---|
actions | map of string → Action | no | — |
chain | string | no | — |
contract_templates | map of string → ContractTemplate | no | — |
description | string | no | — |
manifest_version | string | yes | — |
protocol | string | yes | — |
simplicity_hl | SimplicityHl | no | — |
utxo_types | map of string → UtxoType | no | — |
actions — map of string → Action
Standalone actions that require no template instance (e.g. Prepare).
No description in the schema.
contract_templates — map of string → ContractTemplate
Contract template definitions. Each template has typed fields and actions. An action carrying a create_instance block is a constructor for its template.
No description in the schema.
manifest_version — string, required
Version of the manifest format this file is written against, as specified by ELIP-205 — not the version of any tool that reads it. Checked against FORMAT_VERSION at parse time; see check_format_version for the compatibility rule.
No description in the schema.
simplicity_hl — SimplicityHl
SimplicityHL toolchain settings for this manifest's .simf programs.
utxo_types — map of string → UtxoType
No description in the schema.
Action
| Field | Type | Required | Default |
|---|---|---|---|
allow_change | AllowChange | no | — |
create_instance | InstanceCreate | no | — |
description | string | no | — |
inputs | array of Input | no | — |
intent | string | no | — |
on_post_broadcast | HookBlock | no | — |
on_pre_broadcast | HookBlock | no | — |
outputs | array of Output | no | — |
params | map of string → ParamDef | no | — |
allow_change — AllowChange
Whether the engine may append a change output this action did not declare.
Every output a transaction carries must be written in the manifest. The network fee is the single exception, because it has no manifest spelling. A change output is not an exception: its address and amount are chosen by the engine, so silently adding one moves value to a destination the manifest never named, in an amount nobody wrote down. That is how an oversized collateral input once turned 88,735 satoshis into a miner's fee without a word of warning.
So the default is AllowChange::None: a surplus in any asset — including L-BTC — is an error, and the action must size its inputs to what it spends. Relax it only where the surplus genuinely cannot be predicted:
-
"none"(default) — no change may be added; any surplus is an error. -
"lbtc_only"— the engine may return an L-BTC surplus to the wallet. Use this for ordinary funding actions, where the fee is only known after the size is. A surplus in any other asset is still an error. -
"any"— the engine may return a surplus in any asset.
This governs undeclared change. An output with "destination": "change" is declared, and permits change for its own asset regardless of this setting.
create_instance — InstanceCreate
Constructor-only: defines the new instance written to the instance file.
No description in the schema.
inputs — array of Input
No description in the schema.
One-line statement of what this action does, shown as the first clear-signing screen. Supports {ref} and {ref:symbol} interpolation against the execution context (see preview::interpolate); asset-typed refs must carry :symbol so a wallet can substitute a friendly name (enforced by validate::check_ui).
Named for the intent field in Ethereum's ERC-7730 clear-signing metadata, which plays the same role. Author-supplied, so only as trustworthy as the manifest's own signature chain — never a substitute for what a hardware device verifies. It IS covered by the registry hash (see crate::canonical).
on_post_broadcast — HookBlock
Method-level hook: runs after broadcast (captures txids, asset IDs).
on_pre_broadcast — HookBlock
Method-level hook: runs after inputs are resolved, before PSET is built.
outputs — array of Output
No description in the schema.
params — map of string → ParamDef
Runtime action parameters (Spec §5). Prompted, or set by hooks.
AllowChange
Which assets an action lets the engine return a surplus in, via a change output the manifest did not declare. See Action::allow_change.
Spelled as an enum rather than a boolean because the useful middle case — "return leftover L-BTC, but never move a protocol asset I did not account for" — is the one most funding actions want, and a boolean cannot say it.
-
"none"No undeclared change. A surplus in any asset fails the build. -
"lbtc_only"Only the policy asset (L-BTC) may be returned. -
"any"Any asset may be returned.
BlindingFactors
The blinding factors of one confidential output or input.
A wallet normally draws both factors at random, which is right when nothing but the receiver ever reads them. It is wrong when a covenant reads them: a program that checks its own outputs' commitments (deadcat_v3 requires each recreated reissuance token to advance both factors by exactly one) can only be satisfied by factors the spender chose deliberately. Elements' blind_last offers no way to say which, so the engine runs its own blinding pass whenever this field appears.
Each factor is a 32-byte scalar written as a small decimal ("1"), a 0x-prefixed hex string of up to 64 chars, or a reference (params.X, instance.X) resolving to either — which is how a factor an operator reads off an explorer or a side file reaches the build.
On an output it pins what the builder would otherwise choose. Omitting one leaves it random; omitting both makes the field a no-op. One confidential output must keep a free value_bf: the transaction's blinding factors have to sum to zero and the builder solves the last free one to make that true, so pinning every one of them leaves the transaction unbalanceable. In practice that free output is the change.
On a covenant input it is not a choice but a statement of fact — the factors the UTXO being spent was created with. They are what lets the engine rebuild the confidential prevout the sighash and the introspection jets need, and (for a reissuance) the assetBlindingNonce Elements demands. Both halves are required, and a wrong value is caught before signing: the rebuilt commitments simply will not be the ones on chain.
The factors are public to anyone who reads them here, so this trades the output's confidentiality for reissuability: it hides nothing, it only keeps the commitment well-formed. Elements has no explicit reissuance token (confidential_validation.cpp rebuilds the spent token's generator from the blinding nonce and byte-compares it), so a token that must stay reissuable must stay blinded, with a factor its next spender can reproduce.
Asset blinding factor (abf). Also the value Elements requires as the assetBlindingNonce of any later reissuance spending this output.
Value blinding factor (vbf).
ComputeSpec
How a value is computed: either a plain expression string or a structured spec.
Used in two places, deliberately the same shape: create_instance.fields values and ParamDef::compute.
Hand-deserialized rather than #[serde(untagged)], for the same reason as UiSpec: untagged collapses every inner failure into data did not match any variant of untagged enum ComputeSpec, which hides the one thing the author needs to know. Dispatching on the JSON shape lets ParamCompute's error — naming the offending key or the unknown type — reach the surface.
-
string Simple expression:
"$params.COLLATERAL_ASSET_ID","instance.DEBT - 1". -
object Structured compute —
tapleaf,simf_fn, or an explicitexpr.
ContractTemplate
A contract template: typed field declarations and named methods.
| Field | Type | Required | Default |
|---|---|---|---|
actions | map of string → Action | no | — |
description | string | no | — |
fields | map of string → FieldDef | no | — |
actions — map of string → Action
Actions callable on an instance of this template. Structurally identical to the top-level actions — the only difference is that these run against an instance, so their formulas may reference instance.*. An action carrying a create_instance block constructs a new instance of this template.
No description in the schema.
fields — map of string → FieldDef
Field declarations — names and types only. Values are set by constructors.
FieldDef
A field declaration inside a contract template. Just a name and type; no compute here.
| Field | Type | Required | Default |
|---|---|---|---|
default | string | no | — |
description | string | no | — |
type | string | yes | — |
No description in the schema.
No description in the schema.
No description in the schema.
HookBlock
A hook: a flat map of setter targets to the values they take.
One type serves every hook position — an action's on_pre_broadcast / on_post_broadcast and an input's on_resolved — because they only ever differed in when they run, never in shape.
Targets use dot-path notation: "instance.FOO" — sets a contract-template field "params.FOO" — sets an action param
Values are ComputeSpec, the same type create_instance.fields uses, so all three "name → how to produce a value" maps in the format read alike. In hook position only the expression forms are meaningful; validate rejects the rest (see validate::check_hook).
Within an input's own on_resolved, two bare keywords are self-referential: "asset" resolves to that input's computed issuance asset ID (or its UTXO asset for non-issuance inputs), and "reissuance_token" to the computed reissuance token asset ID.
| Field | Type | Required | Default |
|---|---|---|---|
set | map of string → ComputeSpec | yes | — |
set — map of string → ComputeSpec, required
No description in the schema.
Input
| Field | Type | Required | Default |
|---|---|---|---|
amount_sat | any | no | — |
asset | any | no | — |
blinding | BlindingFactors | no | — |
description | string | no | — |
from_address | string | no | — |
id | string | yes | — |
issuance | any | no | — |
on_resolved | HookBlock | no | — |
optional | boolean | no | — |
required_index | integer | no | — |
sequence | any | no | — |
ui | UiSpec | no | — |
utxo_source | any | yes | — |
witnesses | any | no | — |
No description in the schema.
No description in the schema.
blinding — BlindingFactors
The blinding factors of the covenant UTXO this input spends, when it is confidential. Both halves are required. See BlindingFactors.
No description in the schema.
For utxo_source: "wallet" inputs: constrain coin selection to UTXOs whose scriptPubKey equals this address's. A reference (instance.X / params.X) or a literal address string. Use this to pin an input to a committed address — e.g. so a covenant's collateral is spent from the exact address whose hash it commits to.
No description in the schema.
An Elements asset issuance carried by this input.
{"kind": "new", "asset_amount_sat": <expr>, "inflation_amount_sat": <expr>}— mint a brand-new asset, whose id is derived from this input's outpoint. Either amount may be0(reissuance tokens only, or a fixed supply with no reissuance rights). -{"kind": "reissue", "asset_amount_sat": <expr>, "entropy": <ref>}— mint more of an existing asset by spending its reissuance token.
A reissuance needs the issuance entropy of the original mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value the asset id itself is derived from. It cannot be recovered from anything on chain: the reissuance token UTXO carries no trace of the outpoint that created it. So a constructor has to capture it at the one moment it exists, and hand it back later:
json // in the minting action's create_instance: "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy" // in the reissuing action's input: "issuance": { "kind": "reissue", "asset_amount_sat": "params.PAIRS", "entropy": "instance.YES_ISSUANCE_ENTROPY", "issued_asset": "instance.YES_TOKEN_ASSET" }
issued_asset is optional and is a check, not an input: the engine re-derives the asset id from the entropy and refuses to build if the two disagree. An entropy is opaque, and the byte order block explorers print is the reverse of the one used here — without the check a transposed value still builds a broadcastable transaction that reissues the wrong asset.
Failing that, the entropy may come from provided_inputs.<input_id>.issuance_entropy in the instance file. That works, but it travels with an outpoint override which pins the input for every action sharing its id — long after the pin is correct.
on_resolved — HookBlock
Inline hook evaluated after this input's UTXO is resolved and its issuance attrs (asset, reissuance_token) are computed.
When true, the transaction proceeds even if this UTXO is not found. Spec §6.
⚠️ Parsed but NOT enforced — the engine has no optional-input path, so a missing UTXO fails resolution regardless. examples/dex marks its fee_input optional and does not get that behaviour.
Required transaction input index: 0-based absolute, or negative to count from the end (-1 = last). Spec §6.
⚠️ Parsed but NOT enforced. Nothing in the engine reads this field; inputs land in declaration order and that ordering happens to satisfy the covenants. Manifests assert an index here 106 times and none of it is checked, so a reordering that breaks a covenant's introspection would surface only as an on-chain failure. See validate.rs for where a static check belongs.
Per-input nSequence. Drives BIP68 relative timelocks (the check_lock_distance / check_lock_duration Simplicity jets). Accepts:
{"relative_blocks": <expr>}— block-based relative lock (≤ 65535 blocks){"relative_seconds": <expr>}— time-based relative lock, rounded up to 512s units - a bare integer / expression — raw nSequence value
Omitted → the input stays at Sequence::MAX (relative locktime disabled).
ui — UiSpec
Clear-signing UI hint for this input (net-effect debit line).
"wallet" or {"utxo_type": "..."} or conditional object
Simplicity witnesses for this input: map of witness name → definition.
Must name every witness the input's program declares, and nothing else. A definition is either an object carrying a type — simplicityhl (a concrete value), Signature (a BIP340 signature the engine computes), taproot_leaf (a leaf selector, which is not a program witness and so is exempt from both halves of that rule) — or the bare string "unused" for a witness this spending path does not depend on, which supplies the zero its pruned branch wants.
Nothing is inferred from an omission. Anything left out is an error, at validate time against the .simf and again at run time against the compiled program.
InstanceCreate
Describes the new instance written after broadcast.
An action carrying this block is a constructor — there is no separate flag. The instance is always of the contract template the action is declared in, so the template is not named here: create_instance is only legal inside contract_templates.<T>.actions.*, and always creates a <T>.
| Field | Type | Required | Default |
|---|---|---|---|
fields | map of string → ComputeSpec | yes | — |
fields — map of string → ComputeSpec, required
Maps field names to their initial values. Each value is either a string expression ("$params.FOO") or a compute spec ({ "compute": "tapleaf", ... }).
Output
| Field | Type | Required | Default |
|---|---|---|---|
amount_sat | any | no | — |
asset | any | no | — |
blinding | BlindingFactors | no | — |
condition | string | no | — |
confidential | boolean | no | — |
data | any | no | — |
description | string | no | — |
destination | OutputDestination | yes | — |
id | string | yes | — |
optional | boolean | no | — |
required_index | integer | no | — |
ui | UiSpec | no | — |
No description in the schema.
No description in the schema.
blinding — BlindingFactors
Pin this confidential output's blinding factors instead of letting the builder pick them. See BlindingFactors.
No description in the schema.
Whether this output is blinded. The only place confidentiality is declared: a utxo_type describes an address, and two outputs paying the same covenant address need not agree — deadcat_v3's state-1 address holds blinded reissuance tokens beside an explicit collateral UTXO, because the program introspects one as a Pedersen commitment and the other as a plain amount.
Defaults to true for wallet and address destinations on Liquid, and to false for covenant (utxo_type) destinations, where a Simplicity program usually has to read the value and asset. true on a covenant output is not supported yet and is an error rather than a silent downgrade — the address it produces would be right and the UTXO at it unspendable by the paths that expect a commitment.
OP_RETURN payload, for destination: {"type":"op_return"} outputs. Either a concat(ref, …) string, or an object {"parts": [ … ]} of typed fields (for exact binary layouts — LE integers, program_id, asset-internal bytes). Evaluated to raw bytes and embedded after OP_RETURN. Omit for a bare data-less OP_RETURN (NFT burns).
No description in the schema.
destination — OutputDestination, required
Where this output's value goes. See OutputDestination for the accepted forms.
No description in the schema.
No description in the schema.
Required transaction output index; same semantics and same caveat as Input::required_index (Spec §7) — parsed, never enforced.
ui — UiSpec
Clear-signing UI hint for this output (net-effect credit line).
OutputDestination
Where this output's value goes. A string is change (wallet change, amount auto-computed), wallet (a fresh receive address), or an address / params.X reference resolving to one.
-
string
change,wallet, a literal address, or aparams.X/instance.Xreference that resolves to one. -
object The covenant address derived for a declared
utxo_type.Fields:
args?,compile_params?,utxo_type—?marks an optional field. -
object P2TR output built from a 32-byte script hash.
Fields:
script_hash -
"type": "op_return"op_return/burnembed the output's owndatafield (bare OP_RETURN when absent).feedeclares the fee leg and produces no PSET output of its own. -
object Conditional destination. Parsed but NOT implemented — the engine has no arm for it and skips the output entirely.
ParamCompute
Auto-computation spec for a derived compile param or action param.
Dispatched by type, the same discriminator every other tagged object in the format uses (script.type, destination.type, a witness's type). Note this is the method of computation; the value's data type is ParamDef::type_, one level up. The legacy key lang is still accepted as an alias for the discriminator:
"expr": arithmetic expression over other compile params (pow(base, exp)supported)"tapleaf": compile a.simffile and return its Simplicity tapleaf hash (32 bytes hex)"simf_fn": call a named function in a.simffile and use its return value"wallet": take the value from the executing wallet rather than the manifest, withwalletselecting which (WalletValue)
The wallet variant differs from the others in kind: expr, tapleaf and simf_fn are reproducible by anyone holding the manifest, whereas a wallet_* value depends on who is running the action. They live here anyway because from an author's point of view they answer the same question — where does this value come from, if not the user? — and having two fields for that (the old source) meant two things to check and a name that collided with script.source, a file path.
-
"type": "expr"Fields:
expr -
"type": "tapleaf"Fields:
depends_on?,extra_leaves?,params?,simf—?marks an optional field. -
"type": "script_hash"sha256(scriptPubKey)of an address — the exact value the Simplicityoutput_script_hash/input_script_hashjets return for a UTXO paying it.An address and its script hash are two views of one destination: the covenant commits to the hash, the transaction pays to the address, and if they ever disagree the spend fails on-chain. Deriving one from the other is the only way to keep that true — a manifest that asks for both separately is asking to be given two values that must match and cannot be checked.
Blinding is irrelevant here: a confidential address has the same scriptPubKey as its unconfidential form, so both hash alike (
script_hash_of_addresspins this).Fields:
address -
"type": "hook"A value a hook supplies later in this run — declared here, set by anon_resolved/on_pre_broadcastblock targetingparams.<name>.This exists so a hook cannot invent an identifier. Without it,
"set": { "params.YES_TOKN_ASSET": "asset" }is accepted, fills a slot nobody reads, and surfaces as a wrong covenant address much later; with it,validaterejects the typo and the declaration carries thetypethat byte-order handling depends on.It lives under
computerather than as a separatedeferred: trueflag becausecomputealready means exactly "this value is derived, do not prompt for it" — the only thing that differs here is who derives it. A second flag would need its own prompt-suppression path and would have to define what it means alongside acomputethat is also present. -
"type": "wallet"A value taken from the executing wallet rather than the manifest.Grouped under one tag rather than spread across three so that "is this wallet-derived?" is a single check on
computebefore dispatching onwallet— and so adding a new wallet-derived value does not grow the top-level variant list.Fields:
wallet -
"type": "simf_fn"Call a named function in a.simffile after inputs are resolved. The function is compiled withcompile_paramsas param:: constants. Its runtime input is read frominput(a dot-path into ctx, e.g."params.STATE_BYTES"). The return value is stored as the param value.Fields:
compile_params?,fn?,input?,simf—?marks an optional field.
ParamDef
| Field | Type | Required | Default |
|---|---|---|---|
compute | ComputeSpec | no | — |
default | string | no | — |
description | string | no | — |
type | string | yes | — |
compute — ComputeSpec
How this param's value is derived. When present the user is never prompted.
Either a bare expression string — "instance.PRINCIPAL_AMOUNT * 2" — or a structured spec for the cases an expression cannot express (tapleaf, simf_fn). The bare form is what formula used to be; they were two ways to say "this value is computed, do not ask", so they are now one.
Default value shown as a pre-fill in the prompt.
No description in the schema.
No description in the schema.
SimplicityHl
SimplicityHL toolchain settings — how the .simf programs are compiled, as distinct from what the protocol does.
Deliberately carries no compiler-version field. SimplicityHL has its own simc "<range>"; source directive, which the compiler enforces fail-fast before lexing, across the entry file and every reachable dependency — none of which a manifest key can do. Tooling that wants the requirement without compiling can read it via version::SimcDirective::requirement_of. Declaring it here as well would only create a second place to disagree.
| Field | Type | Required | Default |
|---|---|---|---|
debug_symbols | boolean | no | false |
unstable_features | array of UnstableFeatureName | no | — |
debug_symbols — boolean, default false
Whether covenant .simf programs are compiled with debug symbols included.
This changes the program's CMR and therefore every covenant address, because assert!/panic! embed source info into fail-node commitments. Set it to match the toolchain of any protocol this manifest must interoperate with — e.g. true for simplicity-lending / smplx-sdk, which compiles with debug symbols on.
Defaults to false (production; debug symbols are a transitional feature).
unstable_features — array of UnstableFeatureName
Unstable SimplicityHL compiler features this manifest's programs are allowed to use — the manifest form of simc -Z <name>, one entry per feature:
json "simplicity_hl": { "unstable_features": ["enums"] }
The compiler rejects gated syntax unless the feature is enabled, so a program using enum fails to compile until "enums" is listed here. Enabling a feature the programs don't use is harmless: this only lifts a restriction, it never changes generated code, and therefore never changes a CMR or covenant address.
Manifest-wide rather than per-utxo_type, mirroring simc's own per-invocation -Z flag — the whole point of a gate is that a reader can see, in one place, which unstable syntax this protocol depends on.
Defaults to empty: nothing unstable is enabled.
TapleafParam
A single entry in a ParamCompute::Tapleaf params map. Combines the value reference (compile-param name or literal) with an optional type hint.
Manifest type, e.g. "liquid.asset_id", "u64", "bool". When absent, the type is inferred from the compile-param of the same name.
A compile-param name reference OR a string literal like "1", "true".
TaprootLeafKind
The hashing scheme for a TaprootLeafSpec's payload.
"tapdata"Elements taproot data leaf — the only scheme the engine implements.
TaprootLeafPayloadItem
One item of a taproot leaf payload. Items are concatenated, in order, into the bytes that get hashed as the leaf.
-
string Hex literal taken as raw bytes, e.g. "0x01". Whole bytes only.
-
"type": "u8"Computed value, resolved against the run's params/instance fields and encoded pertype/endian/pad_to.Fields:
align?,endian?,pad_to?,value—?marks an optional field. -
object Reference to a
state_varsentry; itsdefault_valueis encoded as a single u8.Fields:
state_var
TaprootLeafSpec
Describes one additional taproot leaf appended to the Simplicity program leaf.
Each leaf's payload is hashed as tapdata — SHA256(SHA256("TapData") ‖ SHA256("TapData") ‖ payload), which is the value a program computes with jet::tapdata_init(), sha_256_ctx_8_add_* and finalize — then folded into the tap tree with TapBranch/elements in declaration order, matching jet::build_tapbranch. The payload's width must match what the .simf hashes: sha_256_ctx_8_add_32 wants exactly 32 bytes, add_8 exactly 8. A mismatch yields a perfectly valid address that the covenant then refuses to recognize as its own.
| Field | Type | Required | Default |
|---|---|---|---|
payload | array of TaprootLeafPayloadItem | yes | — |
type | TaprootLeafKind | yes | — |
payload — array of TaprootLeafPayloadItem, required
Ordered payload items, concatenated into this leaf's byte string.
type — TaprootLeafKind, required
How the payload is hashed. Only tapdata is implemented, and it was previously accepted as a free string — so any other spelling was silently hashed as tapdata anyway, producing an address whose derivation nobody had written down.
UiDetail
Override the net-effect account/bucket heading (else derived from source/destination).
Suppress this leg from the net-effect diff (e.g. pure protocol data).
Human-readable one-line description of this leg — the only signer-facing text for it (description is not a fallback; see preview::input_label).
Capped at crate::validate::MAX_UI_LABEL characters so it fits one net-effect row alongside the amount and asset symbol. The cap reaches the schema as a maxLength — injected by crate::schema from that constant rather than written here as a literal, so the two cannot drift — and an editor flags an over-long label while typing rather than at validate time.
Optional semantic tag (e.g. "collateral", "auth_nft").
UiSpec
Per-input / per-output UI hint. Accepts either a bare label string ("collateral locked") or a detailed object for finer control.
Hand-deserialized rather than #[serde(untagged)]: an untagged enum reports only data did not match any variant of untagged enum UiSpec, swallowing the real reason. Dispatching on the JSON shape lets UiDetail's own error through, so a misspelled key names itself.
-
string Shorthand for
{ "label": "..." }. -
object
Fields:
group?,hide?,label?,role? —?marks an optional field.
UnstableFeatureName
Unstable SimplicityHL compiler feature (simc -Z <name>).
- imports — Module system syntax: 'use' imports, 'mod' modules, 'as' aliases, 'crate::' paths
- enums — Enum syntax: 'enum' declarations and 'EnumName::Variant' match expressions
One of: "imports", "enums"
UtxoParamDef
One entry of a UtxoType::params interface.
| Field | Type | Required | Default |
|---|---|---|---|
default | string | no | — |
description | string | no | — |
type | string | yes | — |
Value to use when a site binds no args entry for this param.
Evaluated in instance scope: a literal, or instance.X naming a field fixed when the contract was instantiated. Action scope is deliberately unreachable — a value that varies per run is exactly what a site must bind explicitly.
Without a default, every site must bind it, and validate says which ones don't.
No description in the schema.
Manifest type, used as the compile-param type hint (u64, bytes32, liquid.asset_id, …) — the same vocabulary action params use.
UtxoScript
| Field | Type | Required | Default |
|---|---|---|---|
compile_params | map of string → string | no | {} |
extra_leaves | array of TaprootLeafSpec | no | — |
source | string | no | — |
type | string | yes | — |
compile_params — map of string → string, default {}
Per-utxo-type compile param remappings: simf_param_name → compile_param_reference. e.g. { "SCRIPT_HASH": "LENDING_COV_HASH" } passes the value of LENDING_COV_HASH to the simf as SCRIPT_HASH.
extra_leaves — array of TaprootLeafSpec
No description in the schema.
No description in the schema.
No description in the schema.
UtxoType
| Field | Type | Required | Default |
|---|---|---|---|
asset | string | no | — |
description | string | yes | — |
params | map of string → UtxoParamDef | no | — |
script | UtxoScript | no | — |
state_vars | any | no | — |
No description in the schema.
description — string, required
No description in the schema.
params — map of string → UtxoParamDef
This type's parameter interface — everything the address derivation may read.
Declaring it switches the type to a closed scope: script.compile_params and extra_leaves resolve params.X against these params and nothing else. A site binds them with args ({"utxo_type": "t", "args": {"STATE": "params.x"}}), whose values are expressions evaluated in the action's scope.
Without it, the type keeps the legacy behaviour: leaves and compile params resolve against whatever is ambient at each mention. That is what makes one utxo_type derive two different addresses in two actions — params.foo means one thing where the action declares foo and something else where it does not — with no error, because an address is a hash and a wrong one looks exactly like a right one.
script — UtxoScript
No description in the schema.
No description in the schema.
WalletValue
Which wallet-derived value a ParamCompute::Wallet spec resolves to.
-
"key"The wallet's x-only BIP340 pubkey. The wallet chooses the derivation path. -
"script_hash"sha256(scriptPubKey)of the wallet's index-0 explicit output — the committed payout target a covenant checks repayment against. -
"address"The explicit address matchingWalletValue::ScriptHash. The two are a pair: the covenant commits to the hash, the wallet receives at the address, so they must be derived together.
Field types
The type string on a contract template's fields and an action's params.
This one table is maintained by hand: the JSON Schema types these as plain
string, so unlike the manifest field reference it
cannot be generated. It tracks manifest_to_simf_type and prompt.rs in the
reference wallet.
Types a covenant can take
These map onto a SimplicityHL primitive, so a value of one of these types can be
wired into a .simf program through script.compile_params.
| Type string | SimplicityHL type | Written as | Notes |
|---|---|---|---|
u8 | u8 | decimal string | |
u16 | u16 | decimal string | liquid.u16 is an accepted alias |
u32 | u32 | decimal string | |
u64 | u64 | decimal string | |
bool | bool | "true" / "false" | u1 is an accepted alias |
bytes32 | u256 | 64 hex chars | Raw 32 bytes, passed through unchanged |
pubkey | u256 | 64 hex chars | 32-byte x-only BIP340 key. Not byte-reversed |
liquid.asset_id | u256 | 64 hex chars | Byte-reversed before it reaches the program — see below |
liquid.asset_idis the only type that reverses. Asset IDs are displayed in the same backwards order as a txid, so the engine reverses them into natural byte order before handing them to SimplicityHL. That reversal is driven by the declared type and nothing else — the parameter's name is never consulted. A 32-byte asset ID declared asbytes32reaches the covenant byte-swapped relative to aliquid.asset_id, and produces a different, silently wrong address. When a value is an asset ID, say so.
Declaring a type is not optional in practice. Without one the engine falls back to inferring from the name and then the value, refuses to treat anything as an asset ID, and skips the parameter entirely if it cannot decide — which surfaces much later as a covenant address that does not match.
Types only a param can take
Valid on an action param, but with no SimplicityHL mapping, so they cannot be
compiled into a covenant. Use them for values the transaction builder consumes.
| Type string | Written as | Notes |
|---|---|---|
address | bech32 / blech32 string | A Liquid or Elements address, e.g. a payout destination |
Any other string is accepted as a free-text param and prompted for as text.
asset_id and u256 get a formatting hint at the prompt but no covenant
mapping — prefer liquid.asset_id and bytes32, which do.
Where the values live
Field values are stored in the instance as strings, whatever their type: integers as decimal, byte types as hex. See Instance, state & constructors.
Formula language reference
Formulas are string expressions evaluated at transaction build time. They appear
in output/input amount_sat, a param's compute, hook set values, and witness
expr.
Operators
| Operator | Description |
|---|---|
+ - * / | Integer arithmetic (division truncates) |
== != < <= > >= | Comparison (returns boolean) |
&& || ! | Boolean logic |
( ) | Grouping |
References
| Syntax | Description |
|---|---|
instance.NAME | Contract-template field value, from the instance |
params.NAME | Action parameter 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 |
asset | (inside an input's own on_resolved only) that input's asset, including one it has just issued |
reissuance_token | (same) the matching reissuance-token asset |
output_id.amount_sat | Satoshi amount of a constructed output (post-construction) |
fee | Estimated transaction fee (used in change and re-lock formulas) |
The $ prefix
Two spellings of a reference exist, and they are not interchangeable:
| Spelling | Where | Meaning |
|---|---|---|
params.NAME, instance.NAME | inside a formula — amount_sat, a compute expression, a hook set value | a term in an expression that is evaluated |
$params.NAME, $instance.NAME, $inputs.ID.FIELD | as a whole value — a create_instance.fields entry, a witness key | take this value as-is; no arithmetic |
The rule of thumb: $ means "this entire value is that reference". Where a
string is parsed as an expression, write the bare form and the evaluator resolves
it; where a string stands for one value and nothing more, the $ says so.
"create_instance": {
"fields": {
"COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT",
"PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.RATE / 10000"
}
}
Both forms are legal in create_instance.fields: the first copies a resolved
param straight through, the second is an expression that happens to live there.
$inputs.<input_id>.<field> reaches a resolved input — most usefully
$inputs.<id>.issuance_entropy, the value a reissuance later needs, and
$inputs.<id>.issued_asset. It has no bare equivalent; an input's amount and
asset are reached in formulas as input_id.amount_sat and input_id.asset.
Functions
| Function | Signature | Description |
|---|---|---|
pow(base, exp) | (u64, u64) → u64 | Integer exponentiation |
concat(a, b, …) | (bytes…) → bytes | Byte concatenation — OP_RETURN data only, not a general formula function |
There is no way to resolve an input or output
idto its transaction index. A covenant that needs to be told a position (asset_auth'sINPUT_ASSET_INDEX, for instance) takes a literalu32witness, written by hand against the order the inputs and outputs are declared in. See Settling the loan.
Comparison and boolean operators exist but have nowhere to be used on their own: there is no rule block that takes a predicate. They are there for the conditional parts of arithmetic, not for validation.
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 Installing the CLI for
the install options). Manifest paths are relative to your current directory.
Commands
validate <manifest>
Statically check a manifest and report obvious problems — without touching the
network, wallet, or filesystem. Catches unknown utxo_type references, outputs
missing a required amount_sat, duplicate input and output ids, malformed
destinations, hooks writing to targets nothing declares, unreferenced UTXO
types, and clear-signing text a wallet could not render. Exits non-zero if any
errors are found (warnings alone still exit zero).
Note that a misspelled or unknown field does not reach validate: it is a hard
parse error, raised by any command that reads the manifest at all. So is a
manifest_version from a different format revision.
txw validate examples/p2pk/txmanifest.json
Future versions will add deeper checks (compiling SimplicityHL leaves, verifying formula references resolve).
describe <manifest>
Explore a manifest interactively. Presents a menu of the contract's overview,
contract templates, and standalone actions; drill into any template to list its
fields and actions, and into any action to see its params, inputs, outputs and
witnesses — 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> | none | Instance file to read (template field values locked at deploy). Never auto-discovered — pass it for any action that reads instance.*. |
--state <file> | none | State file to read, locating covenant UTXOs. Never auto-discovered — pass it for any action that spends one. |
--instance-out <file> | <stem>.instance.N.json | Where a constructor writes the new instance. |
--state-out <file> | <stem>.state.N.json | Where the run writes state after broadcast. |
--input <id>=<txid>:<vout> | — | Pin one input to a specific outpoint. Repeatable; outranks the state file. |
--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. |
Neither
--instancenor--statehas a default. Leave them off and the run starts with no instance and no state — which is right for a first action, and wrong for every action after it. Outputs are written to a fresh numbered file (txmanifest.state.1.json, then.2.json, …) so a run never overwrites the file it read. Pass the file the previous run wrote.Every run also appends to
<stem>.state.history.json, which is not versioned.
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; see the
manifest field reference for those.
Lifecycle
The following steps are executed in order for each action execution.
1. Parse
Read the target action's params. For an action declared inside a contract
template, also load that instance's fields — those are
reached as instance.NAME. A param carrying a compute block is never prompted
for; nor is one already fixed by provided_inputs.params.
2. User supplies the remaining params
Prompt for every param not covered by the previous step. Present each param's
description as guidance text.
params is the only runtime value namespace — every value the user supplies or a
hook computes lives there.
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, from_address 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.
- Validate each
provided_inputsUTXO against chain state: confirm it is unspent and itsscript_pubkeymatches the expected script. - A pinned outpoint reads its amount and asset from the chain, which outrank anything the manifest or the operator supplies.
4. Hooks run
As each input resolves, run that input's on_resolved block. Once every input is
resolved, run the action's on_pre_broadcast block. Hooks run in declaration
order — the order they appear in the file — which is the only ordering guarantee.
A hook is a set map of target → value, written in the formula language rather
than SimplicityHL. Targets are instance.NAME (write a contract-template field)
or params.NAME. Within an input's own on_resolved, the bare keyword asset
resolves to that input's asset — including one it has just issued — and
reissuance_token to the matching reissuance token.
The context available to a hook is:
- Resolved input outpoints (txid, vout), amounts, and assets for every input resolved so far.
- All
instanceandparamsvalues set so far, including those set by earlier hooks in the same pass.
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. Computed params resolved
Evaluate every param whose compute block has not yet produced a value:
compute.type | Produces |
|---|---|
bare string, or expr | The value of a formula-language expression |
tapleaf | A covenant script hash, by compiling a .simf |
script_hash | sha256(scriptPubKey) of an address |
wallet | A wallet-derived key, script_hash or address |
simf_fn | The return value of a named function in a .simf |
hook | Nothing — the value was supplied by a hook in step 4 |
Where two covenants each commit to the other's hash, seed with 32 zero bytes and iterate to convergence.
6. Outputs constructed
Build the transaction outputs from the action's outputs array. Evaluate each
amount_sat formula against the now-complete context — instance, params,
and resolved input amounts — and resolve each destination to a concrete
scriptPubKey. Blind any output marked confidential, honouring a blinding
block where one pins the factors.
7. 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.
A surplus that no declared output absorbs is governed by the action's
allow_change, which defaults to "none": the action must size its inputs to
what it spends, and an unexplained remainder is an error rather than a silent
change output.
8. Fee review / adjustment → PSET created
Present the user with the final fee amount. If the user adjusts the fee rate, rerun from step 7. 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.
9. Wallet signs
Populate witnesses into the PSET from each input's witnesses map — witnesses
satisfy a specific input's script, so they are declared on the input, not on the
action. The map must name every witness the input's program declares and nothing
else; a witness this spending path does not use is written as the bare string
"unused". Pre-computed witnesses from provided_inputs.witnesses are included
verbatim.
10. 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.
The dry-run is where a manifest's assumptions are actually tested — nothing earlier in the lifecycle checks business rules. It is a cryptographic and covenantal check: it confirms the on-chain scripts will accept the constructed transaction.
11. Broadcast
Finalise and extract the transaction from the PSET. Broadcast to the network, or hand off to an external broadcast service.
12. Post-broadcast
Run the action's on_post_broadcast block, which is where values only known once
the transaction exists — txids, newly issued asset IDs — are captured. Then
update the contract's state atomically with respect to the broadcast: remove
spent UTXOs, add the new covenant outputs, and persist the new instance if the
action carried a create_instance. Where that data lives is your choice — a
file, a database, browser storage — but it must survive to the next action.
Execution context for SimplicityHL code
The following are available to all SimplicityHL code at build time:
| 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 |
instance.NAME | Contract-template fields, plus any values set by hooks that have already run |
params.NAME | Runtime values supplied by the user or computed for the action |
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. |
Reporting failures
A manifest carries no error-code table and no author-supplied failure messages, so there is nothing to look up: report the failure you actually hit. The four a user will meet are a parse error naming the offending key, an input that could not be resolved, a witness that does not match the program, and a covenant the dry-run could not satisfy.
Name the input or field involved in each. A wallet that collapses these into one message makes the difference between "you gave me a bad value" and "this manifest is wrong" invisible, and only the first is something the user can act on.
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: treat every value in it
as hostile until checked against the chain.
Glossary
Action — A single transaction recipe in a manifest: its inputs, outputs, and
the witnesses each input supplies. An action inside a contract template is
identical to a top-level one, except that its formulas may also reference
instance.*.
allow_change — An action's bound on undeclared change: "none" (the
default), "lbtc_only", or "any". Every output a transaction carries must be
written in the manifest; the network fee is the one exception.
Blinding factor — The 32-byte scalar hiding an asset or an amount in a
confidential output. An output's blinding block pins what the builder would
otherwise pick at random — needed by any covenant that verifies its own UTXOs as
Pedersen commitments.
CMR (Commitment Merkle Root) — The 32-byte hash of a compiled Simplicity program. Doubles as the program's on-chain identity.
Compute spec — How a param's value is produced instead of being prompted for:
a bare formula string, or a structured expr, tapleaf, script_hash,
wallet, simf_fn or hook.
Contract template — A typed contract definition with named fields and
actions, under the top-level contract_templates map. Each deployment of a
template is one instance, with its own field values.
Constructor — An action carrying a create_instance block, which creates a
new instance when it runs. There is no separate flag: the block is what
makes the action a constructor, and it is only legal inside a contract template.
Covenant — A script that constrains how its output may be spent — e.g. by introspecting the spending transaction's inputs and outputs.
debug_symbols — The simplicity_hl flag deciding whether covenants compile
with debug information. It changes every program's CMR, and therefore every
address derived from it, so two tools that disagree about it see different
contracts.
Field — A contract template's compile-time value, baked into a covenant
script at deploy time and stored per deployment as part of the instance.
Changing one changes the script's address. Referenced as instance.NAME.
Instance — The field values of one deployment of a contract template. A wallet must keep them: without them it cannot rebuild the contract's addresses.
Manifest — The static JSON protocol definition (txmanifest.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.
Param — An action's runtime value: prompted for, computed, or set by a hook.
It affects only the transaction being built, never an address. Referenced as
params.NAME. params is the only runtime value namespace.
provided_inputs — UTXOs pre-filled alongside an instance, letting a wallet
spend a counterparty's output it never indexed.
PSET — Partially Signed Elements Transaction (the Elements equivalent of a PSBT).
State — The live on-chain UTXO set for one instance, updated after every broadcast. How a wallet stores it is its own business; the format only requires that it survives between actions.
Tapleaf compute spec — A compute spec ("type": "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. Witnesses are declared on the input whose script they satisfy.
$comment / $schema — Authoring keys, legal on any object at any depth and
carrying no protocol meaning. A tool strips both before interpreting a manifest.