Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Welcome to The tx-manifest Cookbook — a recipe-driven guide to writing manifests: machine-readable descriptions of multi-UTXO protocols on Bitcoin and Liquid.

This book is not a top-to-bottom reference manual. It is a cookbook in the style of the Rust Cookbook: a sequence of small, self-contained recipes, each solving one concrete problem and introducing one or two new concepts. You can read it straight through, or jump to the recipe that matches what you are trying to do.

Who this is for

  • Protocol authors who have a Simplicity or miniscript contract and want a portable description of the transactions that drive it.
  • Wallet and tooling developers who want to read a single file and know which UTXOs to watch, which transactions are valid, and which witnesses to construct.
  • Anyone trying to understand an unfamiliar on-chain protocol without reverse-engineering it from source.

How the book is organised

  1. Getting Started explains what a manifest is, gets the tx-manifest-wallet CLI built and a wallet ready, and dissects the top-level structure of a file.
  2. 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.
  3. The Full Walkthrough ties every concept together on a real peer-to-peer lending protocol.
  4. The Appendix is the quick-reference material: type tables, the formula language grammar, and the full CLI command list.

How to read a recipe

Every recipe in the Cookbook follows the same shape:

Problem — one sentence describing the goal.

Recipe — the manifest JSON you can copy and adapt.

How it works — an annotated tour of the new fields.

Run it — the actual tx-manifest-wallet commands to execute the action.

Try next — where to go from here.

Every JSON snippet and command in this book is drawn from real files in the repository (examples/p2pk/txmanifest.json, example/lending/) and the real CLI in txmanifest_wallet — nothing here is invented.

The authoritative reference is Spec.md in the repository root. When this book and the spec disagree, the spec wins. This cookbook aims to teach; the spec aims to be complete.

Let's start with the big picture: what is a manifest?

What is a manifest?

Any multi-UTXO protocol — whether it uses Bitcoin miniscript, Tapscript, or Liquid Simplicity — imposes a specific transaction layout. Covenants that do transaction introspection are especially strict: input 0 must be a specific asset, output 1 must go to a specific script hash, output 2 must carry exactly the right amount. The on-chain program enforces this, but someone still has to document what layout it expects.

Historically that documentation was a PDF, a Notion page, or a comment in the source. It was informal and only useful to the person who wrote it. Anyone else building a wallet integration had to reverse-engineer the expected transaction shapes and hope the docs were current.

A manifest formalises that document. The same information that used to go into prose — "the pre-lock UTXO must be at input index 0, the collateral goes to output 2, the borrower's NFT must be co-spent" — is expressed as structured JSON that tools can read.

The three-file model

A live contract is described by three companion files:

FileNamingWhat it holdsLifetime
Manifesttxmanifest.jsonThe protocol definition: classes, actions, inputs, outputs, witnesses.Static — shared by every deployment.
Instance file<name>.instance.jsonThe compile-time parameters for one deployment (this borrower's pubkey, this loan's amount).Created when the contract is instantiated.
State file<name>.state.jsonThe live on-chain UTXO set for this instance.Updated after every broadcast.

The manifest is the cookbook recipe; the instance file is the specific ingredients you bought; the state file is what's currently in the pot.

For the first several recipes we work only with the manifest — the other two are introduced in Instance, state & constructors.

What a manifest contains

A manifest has a small number of top-level sections:

  • classes — the contract types. Each class has typed fields and methods. A class's fields are the contract's compile-time parameters — the values baked into its covenant scripts (pubkeys, asset IDs, amounts, expiry heights). Changing a field produces a different script, and therefore a different on-chain address. Field values are fixed per deployment and stored in the instance file, not in the manifest.
  • utxo_types — the on-chain states the protocol can create. Each has a known script so a wallet can recognise these outputs on-chain.
  • actions (also called methods) — the valid transactions. Each one says which UTXOs it consumes, which it produces, the amount formulas, and the witnesses needed to satisfy the covenants. Actions live either inside a class's methods or, for simple contracts, at the top level.
  • lifecycle — documentation-only: named states, the transitions between them, and whether each action is cooperative or unilateral.

We dissect each of these in Anatomy of a manifest. But first, let's get the tooling ready.

Setup

To run the recipes in this book (not just read them) you need the tx-manifest-wallet CLI, a wallet, and a connection to a Liquid testnet Esplora server.

tx-manifest-wallet is an example implementation of a wallet. It is a reference tool that consumes manifests and walks through the full build-and-sign lifecycle so the recipes in this book are runnable. It is not the only way to consume a manifest — any wallet can implement the same lifecycle. If you are building your own wallet, see the Wallet implementation guide for the execution lifecycle a wallet follows when executing an action.

Get the CLI

The wallet binary is tx-manifest-wallet. There are four ways to get it; pick whichever suits you. Option 1 (the codespace) is the quickest way to try the recipes — nothing to install.

This book aliases the binary to txw purely to keep the commands short and readable. Every command below is written as txw <subcommand> — read that as tx-manifest-wallet <subcommand> if you prefer the full name. The Blockstream codespace ships the txw alias already; with the other options, add it yourself:

alias txw=tx-manifest-wallet

Option 1 — Blockstream Simplicity codespace (no install)

The Blockstream Simplicity codespace comes with the example wallet preinstalled and already aliased to txw, alongside the SimplicityHL toolchain. Open it in GitHub Codespaces and you can run the recipes immediately — no local setup:

txw --help

Option 2 — download a release binary

If you don't want to compile, grab a prebuilt binary from the releases page. Builds are published for Linux (x86_64), macOS (Apple Silicon), and Windows (x86_64). Download the archive for your platform, unpack it, and put tx-manifest-wallet on your PATH:

# Example: Linux x86_64, release v0.1.0 (substitute the current version)
curl -LO https://github.com/stringhandler/txmanifest-wallet/releases/download/v0.1.0/tx-manifest-wallet-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
tar xzf tx-manifest-wallet-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
sudo mv tx-manifest-wallet /usr/local/bin/
txw --help

Asset names are version-stamped. The macOS Apple Silicon and Windows builds are tx-manifest-wallet-<version>-aarch64-apple-darwin.tar.gz and tx-manifest-wallet-<version>-x86_64-pc-windows-msvc.zip. Check the releases page for the exact file name of the latest version.

Option 3 — asdf

The asdf plugin installs prebuilt release binaries (Linux x86_64 and macOS Apple Silicon; asdf is shell-based, so Windows isn't supported):

asdf plugin add tx-manifest-wallet https://github.com/stringhandler/asdf-tx-manifest-wallet.git
asdf install tx-manifest-wallet latest
asdf set -u tx-manifest-wallet latest
txw --help

Option 4 — build from source

The CLI is the txmanifest_wallet crate of a standard Cargo workspace. From a clone of the repository:

cargo build --release          # binary at ./target/release/tx-manifest-wallet
alias txw="$(pwd)/target/release/tx-manifest-wallet"
txw --help

Throughout the book, commands are written as txw <subcommand>. Manifest paths like examples/p2pk/txmanifest.json are relative to your current directory — run from a clone of the repository (or the codespace) to use the bundled examples.

Configure the network and backend

The CLI keeps a small config file with two keys: the default network and the default Esplora URL. Set them once:

txw config default_network testnet
txw config default_esplora https://blockstream.info/liquidtestnet/api

Run config with no arguments to print the current values:

txw config

Most subcommands also accept --network and --esplora flags to override the defaults per-invocation.

Create a wallet

txw create-wallet --out wallet.json

This writes a new HD wallet to wallet.json. Add --mainnet true for a mainnet wallet; by default it follows your configured default_network.

Inspect it — fingerprint, master xpub, oracle key, and a receive address:

txw info --wallet wallet.json

The wallet derives keys on the BIP86 (taproot) paths the spec expects:

Path (testnet)Path (mainnet)Role
m/86h/1h/0h/0/0m/86h/0h/0h/0/0Wallet signing key
m/86h/1h/1h/0/0m/86h/0h/1h/0/0Oracle key

A compile_params entry with source: { "type": "wallet_key" } is auto-filled from the first path; oracle_key from the second. (More on this in Parameters & validations.)

Fund and sync

Your new wallet is empty. Fund it with Liquid testnet L-BTC from the faucet:

  1. Get your receive address. Run info and copy the receive address it prints:

    txw info --wallet wallet.json
    

    Among the output (fingerprint, xpub, oracle key) is a receive address — copy that value.

  2. 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.

  3. Sync the wallet once the faucet transaction has been broadcast, so the CLI picks up the new UTXO from Esplora:

    txw sync --wallet wallet.json
    

sync scans the chain, updates the persisted wallet state, and prints your balance. To re-print the last known balance without hitting the network:

txw get-balance --wallet wallet.json

Prepare UTXOs for an action

Many actions need several separate UTXOs (one per input). The prepare subcommand inspects an action and, if the wallet doesn't have enough discrete UTXOs, builds and broadcasts a split transaction to create them:

txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json

You can also split manually:

txw split -n 4 --asset lbtc --amount-each 10000 --wallet wallet.json

With a funded, synced wallet you're ready for the first recipe: Hello World: Pay-to-Public-Key.

Anatomy of a manifest

Before writing any recipes, let's look at the skeleton every manifest shares. A manifest is a single JSON document. At the top level it has an envelope of metadata fields followed by the data sections.

{
  "manifest_version": "0.1.0",
  "attestation_version": "1",
  "protocol": "p2pk-simplicity",
  "description": "Pay-to-public-key using a Simplicity checksig program on Liquid.",
  "chain": "liquid",

  "compile_params": { ... },
  "utxo_types":     { ... },
  "classes":        { ... },
  "actions":        { ... },
  "lifecycle":      { ... }
}

The envelope

FieldRequiredPurpose
manifest_versionyesVersion of the tx-manifest format itself. Current: "0.1.0".
protocolyesKebab-case protocol identifier, e.g. "simplicity-lending".
descriptionyesFree-text summary of the whole protocol.
chainno"bitcoin", "liquid"/"elements", or "cross-chain". Defaults to "elements".
attestation_versionnoSchema version for any signatures added to the document.
simplicity_hl_versionnoSimplicityHL compiler version the scripts require.
sourcenoRelative path to the top-level .simf file.
confidential_outputsnoFile-level default for output blinding. See Outputs & destinations.

The data sections

A file carries up to five data sections. Two of them — the contract's compile-time fields and its methods — can be written one of two ways:

  • Grouped into a top-level classes map (the canonical model, used by the lending example). Each entry is a deployable contract type bundling its fields and methods.
  • Flattened into a top-level compile_params block plus a top-level actions map. Simpler, and used by the early recipes in this book.

The other sections — utxo_types and lifecycle — look the same either way. Most files won't carry every section.

classes — typed contracts (fields + methods)

A class is a typed contract definition: one deployable contract type with its compile-time fields and the methods (actions) that operate on it, all grouped under a top-level classes map. Richer protocols use this form — the canonical lending file is built entirely from classes.

"classes": {
  "p2pk_contract": {
    "description": "Pay-to-public-key contract.",
    "fields": {
      "PUBKEY": { "type": "pubkey", "description": "Key that controls spending." }
    },
    "methods": { "Pay": { ... }, "Receive": { ... } }
  }
}

A file may also carry a top-level actions map alongside classes, for utility actions that don't belong to a single instance (the lending file uses this for its Prepare actions). Classes, fields, and methods are covered in full in Instance, state & constructors.

Compile-time parameters — what's baked in at deploy time

Whether they live in a class's fields or a top-level compile_params block, compile-time values parameterize the covenant scripts: a pubkey, an asset ID, a loan amount, an expiry height. They are fixed for a deployment — change one and you get a different script hash, and therefore a different address.

In the canonical model they are the fields of a class (above), and their values are stored in the instance file. Simpler single-type contracts — including the early recipes in this book — may instead declare them in a top-level compile_params block. Both forms are accepted by the tooling:

"compile_params": {
  "user_provided": {
    "PUBKEY": { "type": "pubkey", "description": "Key that controls spending." }
  }
}

Some params are derived rather than supplied — computed from other params or from the outpoints of issuance inputs. See Formulas & derived params.

utxo_types — the on-chain states

Each UTXO type is a named on-chain state with a known script (usually a Taproot address built from a Simplicity leaf). A wallet uses these definitions to recognise the protocol's outputs on-chain.

"utxo_types": {
  "p2pk_output": {
    "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.",
    "script": {
      "type": "simplicity",
      "source": "./p2pk.simf",
      "compile_params": { "PUB_KEY": "PUBKEY" }
    },
    "asset": "lbtc"
  }
}

We cover the script block in detail in Covenant UTXO types.

actions — the valid transactions

Each action (or method) is a single transaction recipe: which UTXOs to consume (inputs), what to create (outputs), what witnesses to provide (witnesses), and what must be true before building (validations).

"actions": {
  "Pay": {
    "description": "Pay a recipient by locking funds into a p2pk output keyed to their public key.",
    "params":  { ... },
    "inputs":  [ ... ],
    "outputs": [ ... ],
    "validations": [ ... ]
  }
}

Classes vs. top-level actions. In richer protocols, actions are grouped inside a classes.<id>.methods block — a class is a typed contract with fields and methods. For simple, single-type contracts, actions can live directly under top-level actions. Structurally a method and an action are identical. We start with top-level actions and introduce classes in Instance, state & constructors.

lifecycle — documentation of the state machine

Purely descriptive: the named states, the transitions between them, and whether each action needs one party (unilateral) or both (cooperative). Tools render diagrams from it, but nothing on-chain depends on it.

"lifecycle": {
  "states": ["paid", "received"],
  "transitions": {
    "Pay":     { "to": "paid" },
    "Receive": { "from": "paid", "to": "received" }
  }
}

The execution model, in brief

When you run an action, a tool like tx-manifest-wallet performs roughly these steps:

  1. Resolve parameters — load compile params, auto-derive wallet keys, apply overrides.
  2. Resolve inputs — find each input UTXO (from state file, wallet, or provided_inputs).
  3. Compute derived params — compile .simf files to get covenant script hashes.
  4. Run validations — abort if any rule is false.
  5. Construct outputs — evaluate amount and asset formulas, resolve destinations.
  6. Build the PSET, sign (computing Simplicity witnesses), and broadcast.
  7. Update the state file — remove spent UTXOs, add new covenant outputs.

You don't need to memorise this yet — each recipe touches the parts it needs. The full sequence is in Spec.md §11.

With the skeleton in hand, let's write our first contract.

Splitting a UTXO

Problem. Turn one large wallet UTXO into several smaller ones — a handy warm-up, and a common prerequisite for actions that need several discrete input UTXOs.

Before the first real contract, here is the gentlest possible manifest: no covenants, no witnesses, no compile parameters. Just one input and a handful of outputs, all to your own wallet. It does one useful thing — split a UTXO into four equal pieces — and in doing so introduces the bare skeleton every manifest shares.

The full manifest is reproduced inline below — save it as txmanifest.json.

Recipe

{
  "manifest_version": "0.1.0",
  "attestation_version": "1",
  "protocol": "utxo-split",
  "description": "Split one wallet UTXO into four equal wallet UTXOs.",
  "chain": "liquid",

  "actions": {
    "Split": {
      "description": "Split a wallet UTXO into four outputs of amount_each, returning any remainder (less fees) as change.",

      "params": {
        "amount_each": {
          "type": "u64",
          "description": "Satoshis to place in each of the four output UTXOs."
        }
      },

      "inputs": [
        {
          "id": "funding_input",
          "description": "A wallet UTXO large enough to cover four outputs plus fees.",
          "utxo_source": "wallet",
          "asset": "lbtc",
          "amount_sat": { "min_amount": "params.amount_each * 4" }
        }
      ],

      "outputs": [
        { "id": "split_0", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
        { "id": "split_1", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
        { "id": "split_2", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
        { "id": "split_3", "destination": "wallet", "amount_sat": "params.amount_each", "asset": "lbtc" },
        { "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }
      ],

      "validations": [
        {
          "id": "amount_nonzero",
          "rule": { "type": "arithmetic", "expr": "params.amount_each > 0" },
          "error": { "code": "INVALID_AMOUNT", "message": "amount_each must be greater than zero" }
        }
      ]
    }
  }
}

How it works

The whole envelope, and nothing else. This file has the required top-level fields (manifest_version, protocol, description, chain) and a single actions block. There are no utxo_types (no on-chain covenant states), no compile_params (nothing is baked into a script), and no classes. A manifest can be this small.

One action parameter. amount_each is an action param of type u64 — you supply it each time you run Split. It is not a compile param: it doesn't change any script or address, it only affects the amounts in this one transaction.

One wallet input. funding_input draws from utxo_source: "wallet" — any wallet-controlled UTXO. Its amount_sat uses the { "min_amount": ... } form with a formula, params.amount_each * 4, so the tool auto-selects a UTXO worth at least four shares. (Operators like * are covered in Formulas & derived params.)

Four equal outputs, plus change. Each split_n output sends amount_each to destination: "wallet", your own receive address. The final change_out has no amount_sat — a change destination automatically receives whatever is left after the four outputs and the fee, and it's optional so the action still works if that remainder is zero.

No witnesses. Every input here is an ordinary wallet UTXO, which the wallet library signs with a standard Schnorr signature. Witnesses only appear when you spend a covenant — which is exactly what the next recipe introduces.

Run it

Make sure you have a funded, synced wallet (Setup). From the repository root:

# Optional: check the manifest's schema before running anything.
txw validate txmanifest.json

txw run txmanifest.json Split \
  --network testnet --wallet wallet.json

You'll be prompted for amount_each; the tool selects an input, builds the four outputs plus change, signs, and broadcasts. Afterwards your wallet holds four fresh UTXOs.

The built-in shortcut. Because splitting is so common, the CLI ships it as a first-class command — no manifest needed:

txw split -n 4 --asset lbtc --amount-each 10000 --wallet wallet.json

And prepare will split automatically when an action needs more UTXOs than the wallet currently has:

txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json

Try next

That's the skeleton. The next recipe adds the first real Simplicity covenant — a UTXO type, a script, and the witnesses to spend it: Hello World: Pay-to-Public-Key.

Hello World: Pay-to-Public-Key

Problem. Lock a Liquid output so that only the holder of one private key can spend it — and meet Simplicity, the on-chain language that enforces the lock.

After the no-covenant warm-up in Splitting a UTXO, this is the first contract with a real covenant: an on-chain program that decides whether a UTXO may be spent. It is the manifest equivalent of println!("Hello, world!").

This lesson covers only the Pay action — locking funds into the covenant. Spending those funds back out (the Receive action) needs a signature witness and gets its own lesson later. The full file is txmanifest.json under examples/p2pk/.

Introducing Simplicity

Until now our outputs went to ordinary wallet addresses. A covenant output is different: its address is a program. On Liquid that program is written in SimplicityHL — a high-level language that compiles to Simplicity — and lives in a .simf file. The compiler turns it into a 32-byte commitment (its CMR), which becomes the output's Taproot address. To spend the output you must supply a witness that makes the program succeed.

A manifest does not contain the program; it points at the .simf file and supplies its compile-time parameters. So a real contract is now two files that live side by side:

your-book-folder/
├── txmanifest.json     ← the manifest (references "./p2pk.simf")
└── p2pk.simf             ← the Simplicity program, compiled into the address

The source path in the manifest is resolved relative to the manifest's own directory, so keep the .simf next to it.

The program: p2pk.simf

Create p2pk.simf with exactly this content:

fn main() {
    let sig: Signature = witness::SIGNATURE;
    jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig);
}

Four pieces:

  • witness::SIGNATURE — a value supplied by the spender at spend time. The program reads it into sig. (We don't supply it in this lesson because Pay only 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 that sig is a valid BIP340 Schnorr signature over that message by PUB_KEY. If it isn't, the program fails and the spend is rejected.

In plain English: "this output may be spent only by a signature from PUB_KEY over this transaction." That is pay-to-public-key.

The manifest

Start with a skeleton

Create txmanifest.json next to p2pk.simf, with every top-level section present but empty:

{
  "manifest_version": "0.1.0",
  "attestation_version": "1",
  "protocol": "p2pk-simplicity",
  "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.",
  "chain": "liquid",

  "utxo_types": {},
  "actions": {}
}

That is the whole shape: the envelope (the first five fields, covered in Anatomy of a manifest) followed by two empty data sections we'll fill in below — the UTXO type and the Pay action. There's no compile_params block: the recipient's key is a runtime parameter of the action, not a value baked in at deploy time. (And no lifecycle, since this lesson has a single action.)

We'll fill the two sections in order.

Fill in the UTXO type

The UTXO type names the on-chain state and points at the .simf program. Replace the empty utxo_types with:

  "utxo_types": {
    "p2pk_output": {
      "description": "A Liquid UTXO locked to a pubkey via the compiled p2pk.simf program.",
      "script": {
        "type": "simplicity",
        "source": "./p2pk.simf"
      },
      "asset": "lbtc",
      "confidential": false
    }
  },

Notice the script block only names the program — it carries no compile_params map. The program still has a PUB_KEY parameter to fill, but we'll supply that per-output, from a value the action receives at run time. That's the next section.

Fill in the Pay action

Finally, the action itself — the value it takes, the UTXOs it consumes, what it creates, and what must hold before it builds. The Pay action declares a pubkey parameter and feeds it into the covenant on the output's destination. Replace the empty actions with:

  "actions": {
    "Pay": {
      "description": "Lock funds into a p2pk output that only the pubkey's owner can spend.",
      "params": {
        "pubkey": {
          "type": "pubkey",
          "description": "The x-only public key that will be able to spend this output (the recipient)."
        },
        "amount_sat": {
          "type": "u64",
          "description": "Amount in satoshis to lock in the output."
        }
      },
      "inputs": [
        {
          "id": "funding_input",
          "description": "Wallet UTXO providing the funds.",
          "utxo_source": "wallet",
          "asset": "lbtc",
          "amount_sat": { "min_amount": "params.amount_sat" }
        }
      ],
      "outputs": [
        {
          "id": "p2pk_out",
          "description": "The funded p2pk output, locked to the recipient's pubkey.",
          "destination": {
            "utxo_type": "p2pk_output",
            "compile_params": { "PUB_KEY": "params.pubkey" }
          },
          "amount_sat": "params.amount_sat",
          "asset": "lbtc"
        },
        {
          "id": "change_out",
          "description": "Change returned to the funding wallet.",
          "destination": "change",
          "asset": "lbtc",
          "optional": true
        }
      ],
      "validations": [
        {
          "id": "amount_nonzero",
          "rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" },
          "error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" }
        }
      ]
    }
  }

The key line is the output's destination: alongside utxo_type it carries a compile_params map, { "PUB_KEY": "params.pubkey" }, wiring the runtime pubkey into the program's param::PUB_KEY just for this output.

With both sections filled in you have the complete file — identical to txmanifest.json under examples/p2pk/.

How it works

The recipient's key is a runtime parameter. pubkey lives under the Pay action's params — you supply it when you run the action (e.g. from the recipient's info output). It has type pubkey (a 32-byte x-only BIP340 key). Nothing about it is baked into the file at deploy time; there's no compile_params block at all.

The output wires that key into the covenant. The p2pk_out destination is { "utxo_type": "p2pk_output", "compile_params": { "PUB_KEY": "params.pubkey" } }. That compile_params map feeds the runtime pubkey into the program's param::PUB_KEY. The tool compiles p2pk.simf with that key and derives the covenant's Taproot address — using a NUMS internal key so the only way to spend is through the script. Because the key is baked into the compiled program, two different keys give two different p2pk_output addresses. (We unpack that derivation in Covenant UTXO types.)

Pay creates the covenant output. The p2pk_out output sends funds to the p2pk_output covenant; the tool computes that covenant's address (from params.pubkey, above) and locks the funds there. The funding_input is a plain "wallet" UTXO, auto-selected via { "min_amount": ... } to cover the amount, and the remainder returns as change.

No witnesses yet. Pay only builds the locked output — it doesn't spend a covenant — so there's nothing to satisfy and no witness to provide. The witness::SIGNATURE in p2pk.simf only matters when you spend the output, which is the next lesson.

Run it

Make sure you have a funded, synced wallet (Setup), and that p2pk.simf sits next to the manifest. From the repository root:

# Optional: check the schema first.
txw validate examples/p2pk/txmanifest.json

# Make sure the wallet has a UTXO big enough for Pay.
txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json

# Lock funds into a p2pk output. You'll be prompted for pubkey and amount_sat.
txw run examples/p2pk/txmanifest.json Pay \
  --network testnet --wallet wallet.json

run prompts for pubkey and amount_sat, compiles p2pk.simf to derive the covenant address, builds the PSET, signs the wallet input, and broadcasts. The new p2pk_output is recorded in the state file, ready to be spent in a later lesson.

Tip. Add --export-pset out.json to write the signed PSET to a file instead of broadcasting, or --debug-jets to 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 info for pubkey. Spending requires signing with that key's private half, so paying to someone else's key means only they can reclaim it.

The state file

After a successful Pay, the tool records the new covenant output in a state file next to your manifest, auto-named txmanifest.state.json:

{
  "last_action": "Pay",
  "utxos": [
    {
      "utxo_type": "p2pk_output",
      "utxo_id": "p2pk_out",
      "txid": "271c1afc4e6137b77874be8d9451a84e122b4bda445d512a45e61eaaddbdaab5",
      "vout": 0,
      "amount_sat": 1000,
      "asset": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"
    }
  ]
}

This is the protocol's live on-chain state: one entry per covenant UTXO the contract currently owns. The p2pk_out output you just created is now tracked as a p2pk_output, keyed by its txid/vout and ready to be consumed as an input by a later action (the Receive spend). Each utxo_id matches the id of the output that produced it. When a UTXO is later spent, the tool removes it here; when an action creates new covenant outputs, it adds them.

No instance file

A contract has up to two companion files: an instance file (compile-time field values) and a state file (live UTXOs). This lesson produced only the state file — there is no txmanifest.instance.json.

Why? Instance files exist to persist a class's fields. This contract declares no classes at all — and the recipient's key (the pubkey action parameter) is supplied fresh at run time, never stored. With no class fields to record, there is nothing for an instance file to hold. Instance files first appear once we introduce classes and constructors in Instance, state & constructors.

Try next

You now have a covenant on-chain. The next recipe digs deeper into parameters — the pubkey and amount_sat values you just supplied — and adds runtime validation: Parameters & validations.

Hello World, Part 2: Spending the output

Problem. Take the covenant output you created in Part 1 and spend it back into your wallet — by producing a signature that satisfies the on-chain program.

Part 1's Pay action only built a covenant output; it locked funds into a p2pk_output and recorded that UTXO in the state file. This lesson adds the Receive action, which spends it. Three new things have to come together:

  1. The state file locates the UTXO. We never type a txid — the tool reads txmanifest.state.json and finds the live p2pk_output entry.
  2. 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.simf with that same key and confirm the address matches.
  3. A witness satisfies the program. p2pk.simf demands a BIP340 signature over the transaction. Receive provides one.

Prerequisites. You must have run Pay first, so txmanifest.state.json holds a p2pk_output. Crucially, in Part 1 you must have locked the funds to one of your own wallet's keys (e.g. the key from info) — because spending now requires signing with that key's private half. If you paid to someone else's pubkey, only they can run Receive.

The Receive action

Add this action alongside Pay in txmanifest.json:

    "Receive": {
      "description": "Spend a p2pk output back into your wallet. Requires a BIP340 signature from the pubkey the output was locked to.",
      "params": {
        "pubkey": {
          "type": "pubkey",
          "description": "The x-only public key the output was locked to in Pay. Must be one of your own wallet's keys so the wallet can sign the spend."
        }
      },
      "inputs": [
        {
          "id": "p2pk_in",
          "description": "The p2pk covenant UTXO to spend, located via the state file by its utxo_type.",
          "utxo_source": {
            "utxo_type": "p2pk_output",
            "compile_params": { "PUB_KEY": "params.pubkey" }
          },
          "witnesses": {
            "SIGNATURE": {
              "type": "Signature",
              "sig_type": "sig_hash_all",
              "source": { "type": "wallet", "key": "params.pubkey" },
              "description": "BIP340 Schnorr signature over the whole transaction, from the recipient key."
            }
          }
        },
        {
          "id": "fee_input",
          "description": "Wallet L-BTC UTXO to pay the network fee.",
          "utxo_source": "wallet",
          "asset": "lbtc",
          "optional": true
        }
      ],
      "outputs": [
        {
          "id": "received_out",
          "description": "The reclaimed funds, sent to your wallet.",
          "destination": "wallet",
          "asset": "lbtc",
          "amount_sat": "p2pk_in.amount_sat"
        },
        {
          "id": "fee_change",
          "description": "L-BTC change from the fee input.",
          "destination": "change",
          "asset": "lbtc",
          "optional": true
        }
      ]
    }

How it works

The input comes from the state file, not your wallet. p2pk_in's utxo_source is { "utxo_type": "p2pk_output" }. Unlike a "wallet" input, this tells the tool to look in txmanifest.state.json for a live UTXO of that type — the very one Pay recorded. That's why this lesson "requires the state file": without it the tool has no idea the UTXO exists.

compile_params rebuilds the covenant address. A covenant UTXO has no key in the usual sense — its address is the compiled program. To spend it, the tool recompiles p2pk.simf and checks the resulting Taproot address against the one the funds are sitting at. That compile needs PUB_KEY, so the input carries the same per-site map you saw on the Pay output: { "PUB_KEY": "params.pubkey" }. Supply the identical pubkey you used in Pay — a different key compiles to a different address, and the UTXO simply won't match.

The SIGNATURE witness satisfies the program. Recall p2pk.simf:

#![allow(unused)]
fn main() {
let sig: Signature = witness::SIGNATURE;
jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig);
}

The program reads witness::SIGNATURE and verifies it against PUB_KEY. The input's witnesses map provides exactly that name:

  • type: "Signature" — the tool computes the signature itself rather than taking a literal value.
  • sig_type: "sig_hash_all" — the message to sign is Simplicity's sig_all_hash, a commitment over the whole transaction. (This is not the classic Bitcoin/Elements SIGHASH_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 the SIGNATURE witness.

Because the program checks the signature against the same PUB_KEY baked into the address, only the holder of that key can produce a spend that succeeds.

Why the separate fee_input. received_out returns the full p2pk_in.amount_sat to your wallet, so there's nothing left over for the network fee. The optional fee_input pulls a small L-BTC UTXO from your wallet; the fee is taken from its fee_change. (If you'd rather, drop the fee input and lower received_out by the fee instead — but a separate fee input keeps the covenant amount clean.)

No path selector needed. p2pk.simf is a single-leaf covenant with one witness, so there's nothing to choose — SIGNATURE is the only witness. Richer covenants with multiple spending paths add a selector witness; that's Multiple spending paths.

Run it

With a funded, synced wallet and a p2pk_output already in the state file from Part 1, run:

txw run examples/p2pk/txmanifest.json Receive \
  --network testnet --wallet wallet.json

run prompts for pubkey (use the same key as in Pay), finds the p2pk_output in the state file, rebuilds the covenant address to confirm the match, builds the PSET, and computes the signature. Before broadcasting it runs a Simplicity dry-run — actually executing the covenant program against the spending transaction to prove the witness satisfies it — then signs, broadcasts, and updates the state file.

Tip. Add --debug-jets to watch bip_0340_verify and sig_all_hash execute during the dry-run, or --export-pset out.json to inspect the spend without broadcasting.

The state file after spending

A successful Receive consumes the covenant UTXO, so the tool removes it from txmanifest.state.json. If that was the only entry, utxos is now empty:

{
  "last_action": "Receive",
  "utxos": []
}

The funds are back in your wallet as an ordinary output. The round trip is complete: Pay moved L-BTC into the covenant and added a state entry; Receive spent it and removed that entry.

Try next

You've now built and spent a covenant — the full lifecycle of the simplest contract. The next recipe looks more closely at the parameters and validation rules that drive these actions: Parameters & validations.

Parameters & validations

Problem. Reject obviously-broken transactions before building them, and understand when a value belongs in compile_params versus an action's params.

The Pay action from recipe 1 would happily let you pay an output of zero satoshis. This recipe adds a validation rule to stop that, and along the way pins down the two kinds of parameters a manifest deals with.

Two kinds of parameters

This trips up everyone at first, so it's worth being precise:

compile_paramsaction params
When fixedAt 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 inthe instance filenowhere; supplied at run time
ExamplePUBKEY, LOAN_EXPIRATION_TIMEamount_sat, CURRENT_BLOCK_HEIGHT

A useful test: "if I changed this value, would the on-chain address change?" If yes, it's a compile param. If it only affects which inputs/outputs this particular transaction picks, it's an action param.

Auto-filled params with source

An action param (or a user-provided compile param) can declare a source so the tool fills it in without prompting:

"params": {
  "BORROWER_PUB_KEY": {
    "type": "pubkey",
    "description": "Borrower's signing key.",
    "source": { "type": "wallet_key" }
  }
}
source.typeResolves toDerivation path (testnet / mainnet)
"wallet_key"32-byte x-only pubkeym/86h/1h/0h/0/0 / m/86h/0h/0h/0/0
"oracle_key"32-byte x-only pubkeym/86h/1h/1h/0/0 / m/86h/0h/1h/0/0

If a param has no source, the tool prompts you for it interactively (or you supply it via --params, below). In recipe 1, PUBKEY has no source, so Pay prompts you for it. The lending protocol's BORROWER_PUB_KEY (above) uses wallet_key, so it's filled from the wallet silently.

Recipe

Add a validations array to the Pay action. Each rule is checked before the PSET is built; if any rule's expression is false, the action aborts with the rule's error message.

"Pay": {
  "description": "Lock funds into a p2pk output that only PUBKEY's owner can spend.",
  "params": {
    "amount_sat": { "type": "u64", "description": "Amount in satoshis to lock." }
  },
  "inputs":  [ ... ],
  "outputs": [ ... ],

  "validations": [
    {
      "id": "amount_nonzero",
      "description": "Must lock a positive amount.",
      "rule": { "type": "arithmetic", "expr": "params.amount_sat > 0" },
      "error": { "code": "INVALID_AMOUNT", "message": "Amount must be greater than zero" }
    }
  ]
}

The other rule type, utxo_exists, guards an action that spends a covenant — you'll add one when you build the spend action in a later lesson. It checks that a UTXO of a given type exists before the action runs:

"validations": [
  {
    "id": "p2pk_exists",
    "description": "A p2pk output must exist before it can be spent.",
    "rule": { "type": "utxo_exists", "utxo_type": "p2pk_output" },
    "error": { "code": "MISSING_UTXO", "message": "No p2pk UTXO found. Has Pay been run?" }
  }
]

How it works

A validation rule has four parts:

FieldRequiredPurpose
idyesUnique name for the rule (shown in errors and logs).
descriptionnoHuman-readable intent.
ruleyesThe check itself — see the two types below.
errorno{ "code", "message" } surfaced when the rule fails.

There are two rule types:

  • arithmetic — the expr is a formula that must evaluate to true. Use it for amount bounds, timelock checks, relationships between params. params.amount_sat > 0 is the simplest case; compile_params.LOAN_EXPIRATION_TIME < params.CURRENT_BLOCK_HEIGHT is a real one from the lending protocol.
  • utxo_exists — names a utxo_type that must have at least one live entry in the state file. Use it as a precondition: don't try to spend something that was never created.

When validations run. All params are resolved and all inputs are selected before validations execute, so a rule can reference resolved input amounts and assets — but it runs before the PSET is constructed, so a failing rule costs nothing. Any failure aborts the whole action.

Error codes. The error.code strings can be collected into a top-level errors map ({ "1": "Loan has not yet expired", ... }) that documents every failure mode the protocol can produce. This is optional but recommended for protocols a wallet will surface to users.

Run it

Validations are invisible on the happy path. To see one fire, run Pay and enter 0 when prompted for amount_sat:

txw run examples/p2pk/txmanifest.json Pay \
  --network testnet --wallet wallet.json
# → aborts with: INVALID_AMOUNT: Amount must be greater than zero

Supplying params non-interactively

Instead of typing params at the prompt, pass a flat JSON file of string→string values and reference it with --params:

{ "amount_sat": "50000", "PUBKEY": "<64-hex-char x-only pubkey>" }
txw run examples/p2pk/txmanifest.json Pay \
  --network testnet --wallet wallet.json --params pay-params.json

The CLI also auto-discovers a per-network param file when you pass --network (see the lending example's *.testnet.json files). An explicit --params file always takes precedence.

Try next

Validations guard the inputs. Next we get precise about the outputs: the different destinations a value can go to, and how confidentiality is decided: Outputs & destinations.

Outputs & destinations

Problem. Send a transaction's value to the right place — a wallet, a change address, a covenant, a raw script hash, or an OP_RETURN — and control whether each output is blinded.

Every action so far produced two outputs: a covenant output and a change output. Those are only two of the destinations available. This recipe is a tour of all of them, plus the rules for output confidentiality.

The output descriptor

An output descriptor has these fields:

FieldRequiredPurpose
idyesUnique name within the action.
destinationyesWhere the value goes. See below.
amount_satusuallyAmount in satoshis — a literal or a formula. Omit it for a change destination, which auto-computes the remainder.
assetyesAsset ID — "lbtc", a 64-char hex ID, or a param reference.
descriptionnoHuman-readable purpose.
required_indexnoForce this output to a specific transaction index.
optionalnoIf true, the output may be omitted (e.g. zero change). Default false.
confidentialnoWhether to blind this output. See the rules below.
datanoOP_RETURN payload — only valid with the op_return destination.

Recipe: every destination type

Wallet and change

{ "id": "to_me",     "destination": "wallet", "amount_sat": "...", "asset": "lbtc" }
{ "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }

wallet is your primary receive address; change is your change address. A change output needs no amount_sat — the tool sends whatever is left after the other outputs and fees. It is almost always optional too, since that remainder can be zero.

An address supplied at run time

{ "id": "recipient_output", "destination": "params.recipient_address", "amount_sat": "params.send_amount_sat", "asset": "lbtc" }

The destination is a bare string referencing an action param of type address — the way to pay an arbitrary recipient address that the user supplies at run time.

A covenant UTXO type

{ "id": "p2pk_out", "destination": { "utxo_type": "p2pk_output" }, "amount_sat": "params.amount_sat", "asset": "lbtc" }

The tool computes the named UTXO type's P2TR address from its .simf source and compile params, and locks the output there. This is how a transaction creates the protocol's on-chain states — exactly what Pay in recipe 1 does with p2pk_output.

A raw script hash from a compile param

{ "id": "relocked", "destination": { "script_hash": "compile_params.PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" }, "amount_sat": 1, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID" }

When you already hold a 32-byte covenant script hash as a derived compile param, embed it directly as a P2TR output without recompiling. The lending protocol uses this to re-lock NFTs under a script-auth covenant.

OP_RETURN

{
  "id": "indexer_op_return",
  "destination": { "type": "op_return" },
  "amount_sat": 0,
  "asset": "lbtc",
  "data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)"
}

An OP_RETURN output is provably unspendable — its value is destroyed. Two common uses:

  1. On-chain discovery. Publish protocol metadata (here, the borrower's pubkey and principal asset, 64 bytes) so an indexer can list the contract without an off-chain database. The data field is a concat(...) formula.
  2. Burning a token. Spend an NFT into OP_RETURN to destroy it — the lending protocol burns auth NFTs this way to prevent reuse.

How confidentiality is decided

On Liquid, outputs can be blinded (amount and asset hidden). The tool resolves blinding in this precedence order:

  1. The per-output confidential field, if present.
  2. The top-level confidential_outputs field, if present.
  3. The chain default: false for Bitcoin, true for Liquid/Elements.

Covenants and OP_RETURN are always unblinded, regardless of the settings above. Simplicity covenants introspect explicit amounts and asset IDs with jets like current_amount and current_asset; they cannot read confidential commitments. This is a hard constraint, not a preference — a blinded covenant output would be unspendable.

Forcing output order with required_index

Covenants that introspect the transaction often require outputs in an exact order ("collateral at output 0, principal at output 1"). Pin an output's position with required_index:

{ "id": "lending_collateral_out", "required_index": 0, "destination": { "utxo_type": "lending_collateral" }, ... }
{ "id": "principal_to_borrower",  "required_index": 1, "destination": "params.borrower_address", ... }

Positive indices are absolute (0-based). Negative indices count from the end (-1 is the last output). The same field exists on inputs — see Multiple spending paths, where covenant input ordering matters too.

Run it

Outputs are exercised by every action; there is no standalone command. To inspect exactly what an action will produce without broadcasting, export the PSET and decode it:

txw run examples/p2pk/txmanifest.json Pay \
  --network testnet --wallet wallet.json --export-pset pay.pset.json

The exported file lists every output with its amount, asset, and scriptPubKey, so you can confirm the destinations resolved as you intended.

Try next

We've now described value flowing out. Spending a covenant requires witnesses to satisfy its Simplicity program — signatures, path selectors, and computed values. That's the next recipe: Witnesses.

Witnesses

Problem. Provide the values a Simplicity covenant needs to authorise a spend — signatures and branch selectors — and understand what the tool does with them.

You met your first witness in Hello World, Part 2: the SIGNATURE that satisfied p2pk.simf. This recipe steps back and covers the witnesses map in full — what it is, the two kinds the tool produces, and the ones you don't have to supply.

A witness only matters when you spend a covenant. Creating a covenant output (Pay) commits to a program; nothing is checked. Spending it (Receive) runs the program, and the program reads its witnesses to decide whether to allow the spend.

Where witnesses live

Witnesses sit on an input — specifically a covenant input (utxo_source is a utxo_type, not "wallet"). Plain wallet inputs sign themselves the ordinary way and have no witnesses map.

{
  "id": "p2pk_in",
  "utxo_source": { "utxo_type": "p2pk_output", "compile_params": { "PUB_KEY": "params.pubkey" } },
  "witnesses": {
    "SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "params.pubkey" } }
  }
}

Each key is a SimplicityHL witness name — it must match a witness::NAME in the .simf source. Our program reads witness::SIGNATURE, so the map has a SIGNATURE entry. A program with witness::PATH and witness::SIGNATURE would have entries for both.

The two kinds of witness

The reference tool produces exactly two witness types. (The spec sketches more; see Not yet wired up below.)

Signature — a computed BIP340 signature

This is the one from Part 2. You don't write a signature by hand; the tool computes it while signing.

"SIGNATURE": {
  "type": "Signature",
  "sig_type": "sig_hash_all",
  "source": { "type": "wallet", "key": "params.pubkey" }
}
  • sig_type: "sig_hash_all" selects the message to sign: Simplicity's sig_all_hash, a commitment over the whole transaction. This is not the classic Bitcoin/Elements SIGHASH_ALL — it's Simplicity's own hash, computed via the transaction environment (CTxEnv::sighash_all()). It's currently the only sig_type defined.
  • source: { "type": "wallet", "key": ... } identifies the signing key. The key resolves to an x-only pubkey — from an action param (params.pubkey, as here), a compile param / class field (compile_params.BORROWER_PUB_KEY, the form the lending example uses), or a literal hex value. The tool searches your wallet's BIP86 derivation paths for the private key matching that pubkey and signs with it.

Under the hood the tool computes the hash, signs it, and rewrites the entry as a simplicityhl witness holding the 64-byte signature as 0x… hex — so a Signature is really sugar over the next kind.

simplicityhl — a literal typed value

A fixed value, parsed against the witness's type. Use it for branch selectors, indices, and raw byte values.

"PATH": {
  "type": "simplicityhl",
  "value": "Left(())",
  "simplicity_type": "Either<(), ()>",
  "description": "Take the first spending path."
}
  • value is a SimplicityHL value expression: Left(()) / Right(()) to choose a branch of an Either, 0x<hex> for a byte array, 42 for an integer.
  • simplicity_type is optional and documentary. The tool takes the real type from the compiled program's ABI, not from this field — it's there to help a human reader. Provide it for clarity; leave it off and nothing breaks.

Branch selectors are the most common use. A covenant with two spending paths typically reads a witness::PATH of type Either<(), ()>; supplying Left(()) or Right(()) picks which path runs. That's the subject of Multiple spending paths.

The witnesses you don't supply

A program declares every witness it could read, but a single spend only travels one path. You supply witnesses for the path you're taking; any witness you omit is filled with a zero value automatically.

That's not a fallback for forgetfulness — it's by design. Before Simplicity prunes the unused branches, every witness node needs some concrete value. Witnesses on branches you didn't take (e.g. the SIGNATURE on a cancel path when you chose PATH = Left) are zeroed, then pruned away and never executed. So:

Supply only the witnesses on the path you're spending. The rest take care of themselves.

This is why our single-path p2pk.simf needs only SIGNATURE, and why a two-path covenant needs PATH plus the witnesses for the chosen branch — not both branches' worth.

What the tool builds

Once witnesses are resolved, the tool satisfies the program against the spending transaction and writes the final Simplicity tapscript witness stack — exactly four items, in this order:

[ witness_bits, pruned_program, cmr_script, control_block ]

You never assemble this yourself; it's the output of finalisation. The Simplicity dry-run executes the program against this stack before broadcast, so a missing or wrong witness is caught locally rather than rejected by the network.

Not yet wired up

The spec and some example files reference two further witness types — formula (a computed value such as index_of(some_output)) and taproot_leaf (a leaf/control-block selector). The current reference tool does not consume these — only simplicityhl and Signature are processed, and the control block is derived from the covenant's leaf structure regardless. Treat formula and taproot_leaf as forward-looking until the tooling catches up; if you put them on an input today, the witness is simply zeroed like any unsupplied value.

See also

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:

PathWhoWhenEffect
Refreshowner's hot keyany timemoves the funds but repeats the covenant
ColdBreakowner's cold keyany timespends out, ending the covenant
Inheritheir's keyafter 180 days of no movementspends 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:

  1. The keys and the timelock are compile parameters (param::INHERITOR_PUB_KEY, param::HOT_PUB_KEY, param::COLD_PUB_KEY, and param::INHERIT_BLOCKS) instead of hardcoded constants, so the manifest can wire them — exactly like PUB_KEY in Hello World.
  2. The path is chosen by a dedicated SPEND_PATH witness, and each signature is its own witness. The upstream version nested the signatures inside the selector; tx-manifest-wallet computes signatures as standalone Signature witnesses, so we split them out (the idiom from Witnesses).
fn main() {
    match witness::SPEND_PATH {
        Left(inherit: ()) => inherit_spend(witness::INHERITOR_SIG),
        Right(cold_or_hot: Either<(), ()>) => match cold_or_hot {
            Left(cold: ()) => cold_spend(witness::COLD_SIG),
            Right(hot: ()) => refresh_spend(witness::HOT_SIG),
        },
    }
}

SPEND_PATH has type Either<(), Either<(), ()>>, so the three branches are selected by Left(()), Right(Left(())), and Right(Right(())). Whichever branch you take reads exactly one signature witness; the other two live on pruned branches and are auto-zeroed.

The two interesting helpers:

#![allow(unused)]
fn main() {
fn inherit_spend(inheritor_sig: Signature) {
    let blocks: Distance = param::INHERIT_BLOCKS;   // configurable timelock (a compile param)
    jet::check_lock_distance(blocks);
    checksig(param::INHERITOR_PUB_KEY, inheritor_sig);
}

fn recursive_covenant() {
    assert!(jet::eq_32(jet::num_outputs(), 2));                 // exactly 2 outputs
    let this_script_hash: u256 = jet::current_script_hash();
    let output_script_hash: u256 = unwrap(jet::output_script_hash(0));
    assert!(jet::eq_256(this_script_hash, output_script_hash)); // output 0 = same covenant
    assert!(unwrap(jet::output_is_fee(1)));                     // output 1 = fee
}
}

inherit_spend enforces a relative timelock — the heir's spend is only valid once the UTXO is INHERIT_BLOCKS blocks old (a compile param; ~180 days ≈ 25,920 one-minute Liquid blocks). recursive_covenant (used by the hot-key refresh) forces the spend to recreate the same covenant in output 0 and have the explicit fee in output 1, with nothing else.

The manifest

A will is something you deploy once and then operate — exactly what a class models. We define a last_will_contract class whose fields are the three keys, and whose methods are the four actions. A constructor method (Fund) records the keys in an instance file the first time you set the will up; the spend methods read them back.

"classes": {
  "last_will_contract": {
    "fields": {
      "INHERITOR_PUB_KEY": { "type": "pubkey" },
      "HOT_PUB_KEY": { "type": "pubkey" },
      "COLD_PUB_KEY": { "type": "pubkey" },
      "INHERIT_BLOCKS": { "type": "u16" }
    },
    "methods": { "Fund": { ... }, "ColdBreak": { ... }, "Refresh": { ... }, "Inherit": { ... } }
  }
}

The last_will UTXO type (top-level, as before) wires those three fields into the program — the field names double as the compile params the script consumes:

"utxo_types": {
  "last_will": {
    "script": {
      "type": "simplicity",
      "source": "./last_will.simf",
      "compile_params": {
        "INHERITOR_PUB_KEY": "INHERITOR_PUB_KEY",
        "HOT_PUB_KEY": "HOT_PUB_KEY",
        "COLD_PUB_KEY": "COLD_PUB_KEY",
        "INHERIT_BLOCKS": "INHERIT_BLOCKS"
      }
    },
    "asset": "lbtc"
  }
}

The constructor: Fund

Fund does double duty — it locks the funds and writes the instance file. It takes the three keys as params (two auto-filled from your wallet) and an amount, locks a wallet UTXO into the covenant, then create_instance records the keys:

"Fund": {
  "is_constructor": true,
    "params": {
      "INHERITOR_PUB_KEY": {
          "type": "pubkey",
          "description": "The heir's x-only public key. They can claim the funds 180 days after the last move."
      },
      "HOT_PUB_KEY": {
          "type": "pubkey",
          "description": "Owner's hot key. Auto-filled from your wallet signing key."
      },
      "COLD_PUB_KEY": {
          "type": "pubkey",
          "description": "Owner's cold key. Your wallet's oracle key — the covenant escape hatch."
      },
      "INHERIT_BLOCKS": {
          "type": "u16",
          "default": "25920",
          "description": "Blocks of inactivity before the heir may claim. ~180 days ≈ 25920 (1-minute Liquid blocks). Max 65535."
      },
      "amount_sat": {
          "type": "u64",
          "description": "Amount in satoshis to place under the will."
      }
  },
  "inputs": [
        {
            "id": "funding_input",
            "description": "Wallet UTXO providing the funds.",
            "utxo_source": "wallet",
            "asset": "lbtc",
            "amount_sat": {
                "min_amount": "params.amount_sat"
            }
        }
    ],
 "outputs": [
      {
          "id": "will_out",
          "description": "The funded last-will output.",
          "destination": {
              "utxo_type": "last_will"
          },
          "amount_sat": "params.amount_sat",
          "asset": "lbtc"
      },
      {
          "id": "change_out",
          "description": "Change returned to the funding wallet.",
          "destination": "change",
          "asset": "lbtc",
          "optional": true
      }
  ],
  "create_instance": {
    "class": "last_will_contract",
    "fields": {
      "INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY",
      "HOT_PUB_KEY": "$params.HOT_PUB_KEY",
      "COLD_PUB_KEY": "$params.COLD_PUB_KEY",
      "INHERIT_BLOCKS": "$params.INHERIT_BLOCKS"
    }
  }
}

HOT_PUB_KEY auto-fills from your wallet signing key; COLD_PUB_KEY is your wallet's oracle key (take it from info — see Setup); the heir gives you INHERITOR_PUB_KEY. Each param value is written into the compile params, so will_out's covenant address is computed from the keys you just supplied — before the instance exists. After broadcast, create_instance writes those same three keys into txmanifest.instance.json.

One instance per will. Unlike Hello World — which had no classes and so no instance file — the keys here are a class's fields, persisted at construction. Every later spend reads them from the instance, so you only enter the keys once. This is the full class / instance model from Instance, state & constructors.

Each spend reads the instance

Every spend is a method whose input is the last_will UTXO (found in the state file) with a SPEND_PATH selector and the matching Signature. The signature keys reference compile_params.* — the fields loaded back from the instance file, so you never re-enter them. ColdBreak:

"ColdBreak": {
  "inputs": [
    {
      "id": "will_in",
      "utxo_source": { "utxo_type": "last_will" },
      "witnesses": {
        "SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
        "COLD_SIG": {
          "type": "Signature",
          "sig_type": "sig_hash_all",
          "source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
        }
      }
    },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
  ],

```json
"ColdBreak": {
  "inputs": [
    {
      "id": "will_in",
      "utxo_source": { "utxo_type": "last_will" },
      "witnesses": {
        "SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
        "COLD_SIG": {
          "type": "Signature",
          "sig_type": "sig_hash_all",
          "source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
        }
      }
    },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
  ],
  "outputs": [
    { "id": "to_wallet", "destination": "wallet", "asset": "lbtc", "amount_sat": "will_in.amount_sat" },
    { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

This is exactly the Hello World spend plus a SPEND_PATH selector. Inherit is identical but with SPEND_PATH = Left(()) and INHERITOR_SIG.

Refresh is the one that's different — the covenant forces it to re-lock:

"Refresh": {
  "inputs": [
    {
      "id": "will_in",
      "utxo_source": { "utxo_type": "last_will" },
      "witnesses": {
        "SPEND_PATH": { "type": "simplicityhl", "value": "Right(Right(()))" },
        "HOT_SIG": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "compile_params.HOT_PUB_KEY" } }
      }
    }
  ],
  "outputs": [
    {
      "id": "will_again",
      "destination": { "utxo_type": "last_will" },
      "asset": "lbtc",
      "amount_sat": "will_in.amount_sat - fee",
      "required_index": 0
    }
  ]
}

Three things the covenant dictates here:

  • required_index: 0recursive_covenant checks output_script_hash(0), so the re-locked output must be output 0.
  • No change output. The program asserts exactly two outputs (covenant + fee). Because this action declares no "change" output, the builder never adds one — it folds the L-BTC surplus into the fee. There's no separate fee input either: a recursive covenant can't add a wallet change output to return the leftover, so the will pays its own fee and shrinks by it each refresh.
  • amount_sat: "will_in.amount_sat - fee" — the fee keyword. The will is re-locked at its current value minus the network fee. The fee output (output
    1. then ends up being exactly fee.

The fee keyword. fee is a reserved formula word for the estimated network fee. It evaluates to 0 while the outputs are first assembled, then the tool estimates the fee from the transaction's size and re-evaluates any amount that used fee — so will_in.amount_sat - fee lands on the right value before signing. No fee_sat param 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 — like Refresh — get exactly their declared outputs plus the fee, which is what a recursive covenant needs.

Skip the prompts: a params file

Fund needs three pubkeys. Typing them in is error-prone, so the CLI can read them from a params file: a flat JSON object of param → value that pre-fills (or fully supplies) the prompts.

You don't even pass a flag. The tool auto-discovers a file named <stem>.<network>.json next to the manifest (the <stem> is the manifest's filename stem) — so for testnet it loads txmanifest.testnet.json from examples/last_will/:

{
  "INHERITOR_PUB_KEY": "…heir's wallet pubkey…",
  "HOT_PUB_KEY":       "…owner's wallet pubkey…",
  "COLD_PUB_KEY":      "…owner's oracle pubkey…",
  "INHERIT_BLOCKS":    "25920",
  "amount_sat":        "100000"
}

(An explicit --params <file> works too, and overrides the auto-discovered one.)

In this example the heir is a second wallet, so two of the keys come from one wallet and one from another. Rather than copy three pubkeys out of info by hand, the book ships scripts that do it for you:

.\create_wallet.ps1          # owner wallet -> wallet.json
.\create_inherit_wallet.ps1  # heir wallet  -> wallet-inherit.json
.\make_params.ps1            # reads both, writes examples/last_will/txmanifest.testnet.json

make_params.ps1 runs info on each wallet, pulls out the signing and oracle pubkeys, and writes the params file — INHERITOR_PUB_KEY from the heir wallet, HOT_PUB_KEY / COLD_PUB_KEY from the owner wallet. (HOT_PUB_KEY also auto-fills from the wallet at run time, since it's a wallet_key source; the file just makes every value explicit.)

Run it

Run everything from the repository root, where the scripts put the wallets and params file. With the params file in place, construct the will — Fund reads every value from the file, so there's nothing to type. It locks the funds and writes examples/last_will/txmanifest.instance.json:

txw run examples/last_will/txmanifest.json Fund \
  --network testnet --wallet wallet.json

Then the cold-key break-out is the most straightforward spend to run, since your oracle key signs it — and you don't re-enter any keys, because they're read from the instance:

txw run examples/last_will/txmanifest.json ColdBreak \
  --network testnet --wallet wallet.json

ColdBreak finds the last_will UTXO in the state file, rebuilds the covenant address from the instance's three fields, signs with the cold (oracle) key, dry-runs the program down the Right(Left(())) branch, and broadcasts.

Caveats on the other two paths. Refresh and Inherit exercise covenant features the reference tool doesn't fully drive yet:

  • Inherit needs the input's relative timelock (nSequence) set to ≥180 days for check_lock_distance to pass, and is signed by the heir's key — not your wallet. It's the dead-man's-switch path; treat it as illustrative until relative-locktime support lands.
  • Refresh relies on the explicit-fee / no-change output layout with the fee keyword. 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 script block as the tool reads it: type: "simplicity", a source path to the .simf file, and a compile_params map wiring manifest params onto the program's param::* names (e.g. { "PUB_KEY": "PUBKEY" }).
  • How the tool turns that into an address: compile the .simf → CMR → a Taproot output with a NUMS internal key, so the key-path is unspendable and every spend goes through the script.
  • canonical_cmr: the CMR with params zeroed — a stable identifier a wallet uses to recognize the program independent of instance parameters.
  • Covenant address determinism: same .simf + same params → same address, always. The P2TR(NUMS, tapbranch(...)) construction.
  • extra_leaves for appending additional taproot leaves.

See Spec.md §14 "Covenant Address Determinism" in the meantime.

Multiple spending paths

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

Problem. Build a covenant that can be spent in more than one way — e.g. a cooperative path and a cancel/timeout path — and select between them at spend time.

🚧 This recipe is a stub. Outline of what it will cover:

  • A Simplicity program with Either<(), ()> paths (PATH::LEFT / PATH::RIGHT).
  • Selecting a path with a simplicityhl witness: Left(()) vs Right(()).
  • Worked example: the lending pre_lock covenant — SetupLending takes the left path; CancelOffer takes the right path with a borrower signature.
  • Using required_index on inputs so the covenant's introspection lines up.
  • Cooperative vs unilateral paths, and how they show up in lifecycle.

See the pre_lock discussion in the lending walkthrough in the meantime.

Formulas & derived params

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

Problem. Compute amounts, indices, and parameter values from other values instead of hard-coding them.

🚧 This recipe is a stub. Outline of what it will cover:

  • Where formulas appear: output/input amount_sat, validation expr, hook set values, witness expr.
  • Operators (+ - * /, comparisons, && || !) and references (compile_params.X, params.X, input_id.amount_sat, input_id.asset, input_id.present).
  • Functions: pow(base, exp), index_of(id), concat(...).
  • The special fees value used in change formulas.
  • Derived params ("derived": true): interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000, and params derived from issuance outpoints.

See Spec.md §9 for the formula grammar in the meantime.

Asset issuance & NFTs

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

Problem. Mint Liquid assets — including single-unit NFTs used as bearer tokens — as part of an action, and derive their asset IDs.

🚧 This recipe is a stub. Outline of what it will cover:

  • The input issuance spec: kind (new / reissue), asset_amount_sat, inflation_amount_sat.
  • Why an asset ID is derived from the outpoint of the issuance input, and how to reference the result as input_id.asset afterward.
  • NFTs as bearer tokens (amount = 1) authorising covenant paths.
  • Encoding protocol terms in an NFT's amount field via bit-packing (the lending protocol's parameter NFTs).
  • Burning NFTs with OP_RETURN to prevent reuse.

See the IssueNFTs action in the lending walkthrough in the meantime.

Hooks & tapleaf compute

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

Problem. Compute and store values mid-action — especially covenant script hashes that depend on other covenant script hashes.

🚧 This recipe is a stub. Outline of what it will cover:

  • Hook blocks: on_resolved (per input) and on_pre_broadcast (per action), each running set assignments in declaration order.
  • Assignment targets: compile_params.X, params.X, args.X.
  • The tapleaf compute spec (lang: "tapleaf"): compiling a .simf to a covenant script hash, with params and depends_on.
  • Circular dependencies: when two covenants each reference the other's hash, seed with 32 zero bytes and iterate to convergence.

See Spec.md §5.5 and §11 Step 3 in the meantime.

Instance, state & constructors

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

Problem. Deploy a contract once and then act on it repeatedly — persisting the compile params and tracking the live UTXO set across transactions.

🚧 This recipe is a stub. Outline of what it will cover:

  • The three-file model in practice: manifest / instance / state.
  • Classes: grouping methods under a typed contract with fields.
  • Constructors (is_constructor: true) and create_instance: writing the instance file with resolved field values.
  • The state file: how covenant outputs are added and spent inputs removed after each broadcast.
  • provided_inputs: pre-filling a counterparty's UTXO inline (the website-to-wallet integration pattern).

See Spec.md §12–13 in the meantime.

The lending protocol

📝 Draft. This chapter has not been reviewed yet — content may be incomplete or change.

🚧 This chapter is a stub. It will tie every cookbook concept together on a real peer-to-peer collateralised lending protocol on Liquid: NFT ownership, Simplicity covenants, partial repayments, vault accumulation, and liquidation.

The complete lending example lives in the repository at examples/lending/ (its txmanifest.json plus the covenant .simf programs). This chapter will adapt it into the cookbook, cross-linking each section back to the recipe that introduced the concept:

  • Lifecyclerecipe 6
  • Params (compile, derived, NFT-encoded) → recipes 2 & 7
  • UTXO types (covenants, script-auth)recipe 5
  • IssueNFTsrecipe 8
  • LockCollateral, SetupLending, RepayLoan, … → recipes 3, 4 & 9

Field type reference

The type strings used in compile_params, class fields, and action params.

Type stringRust equivalentDescription
u8u88-bit unsigned integer
u16u1616-bit unsigned integer
u32u3232-bit unsigned integer
u64u6464-bit unsigned integer
bytes32[u8; 32]32-byte raw byte array
pubkey[u8; 32]32-byte x-only BIP340 Schnorr public key
liquid.asset_id[u8; 32]Liquid asset ID (32 bytes)
addressstringA bech32/blech32 address (used by action params)

Integer field values are written as decimal strings in instance files; byte types as hex strings. See Spec.md §4.2 and §12.

Formula language reference

Formulas are string expressions evaluated at transaction build time. They appear in output/input amount_sat, validation expr, hook set values, and witness expr.

Operators

OperatorDescription
+ - * /Integer arithmetic (division truncates)
== != < <= > >=Comparison (returns boolean)
&& || !Boolean logic
( )Grouping

References

SyntaxDescription
compile_params.NAMECompile parameter by name
params.NAMEAction parameter by name
args.NAMEAction argument by name
input_id.amount_satSatoshi amount of a resolved input
input_id.assetAsset ID of a resolved input (hex string)
input_id.presentBoolean — whether an optional input was found
output_id.amount_satSatoshi amount of a constructed output (post-construction)
feesEstimated transaction fee (used in change formulas)

Functions

FunctionSignatureDescription
pow(base, exp)(u64, u64) → u64Integer exponentiation
index_of(id)(input or output id) → u32Transaction index of a named input/output
concat(a, b, …)(bytes…) → bytesByte concatenation (OP_RETURN data only)

See Spec.md §9 for the authoritative grammar.

CLI reference

The tx-manifest-wallet CLI (txmanifest_wallet) executes manifest actions interactively. This book invokes it as txw <subcommand> (an alias for tx-manifest-wallet — see Setup for the install options). Manifest paths are relative to your current directory.

Commands

validate <manifest>

Statically check a manifest's schema and report obvious problems — without touching the network, wallet, or filesystem. Catches unknown utxo_type references, outputs missing a required amount_sat, duplicate input/output/ validation ids, malformed destinations, unknown validation rule types, create_instance referencing a missing class, unreferenced UTXO types, and lifecycle transitions that don't match any action. Exits non-zero if any errors are found (warnings alone still exit zero).

txw validate examples/p2pk/txmanifest.json

Future versions will add deeper checks (compiling SimplicityHL leaves, verifying formula references resolve, checking canonical_cmr values).

describe <manifest>

Explore a manifest interactively. Presents a menu of the contract's overview, classes, and standalone actions; drill into any class to list its fields and methods, and into any action to see its params, inputs, outputs, witnesses, and validations — without reading the raw JSON. When stdout is not a terminal (e.g. piped to a file or less), it prints a full non-interactive dump of everything instead.

txw describe examples/lending/txmanifest.json

run <manifest> <action>

Walk through the lifecycle of a manifest action interactively: resolve params and inputs, validate, build the PSET, dry-run the Simplicity covenant, sign, and broadcast.

FlagDefaultPurpose
--network <net>config default_networkNetwork for param-file auto-discovery.
--params <file>Flat JSON string→string overrides (takes precedence over auto-discovered file).
--wallet <file>wallet.jsonWallet for input selection and signing.
--data-dir <dir>platform data dirWhere wallet state is persisted.
--instance <file><stem>.instance.jsonInstance file (compile params locked at deploy).
--state <file><stem>.state.jsonState file tracking live UTXOs.
--manual-inputsoffPrompt for every input instead of auto-selecting.
--export-pset <file>Write signed PSET/tx to a file instead of broadcasting.
--debug-jetsoffPrint every Simplicity jet call during dry-runs.

create-wallet

Create a new wallet JSON file. --out <file> (default wallet.json), --mainnet <bool> (defaults to config network).

info

Show wallet fingerprint, master xpub, oracle pubkey, and a receive address. --wallet <file>.

sync

Sync wallet state against an Esplora server and print the balance. --wallet <file>, --esplora <url>, --data-dir <dir>.

get-balance

Print the last known balance from persisted state (no network call). --wallet <file>, --data-dir <dir>.

prepare <manifest> <action>

Ensure the wallet has the UTXOs an action needs; broadcasts a split transaction if not. --wallet, --esplora, --data-dir, --split-amount <sats> (default 10000).

split

Split a wallet asset into N equal UTXOs and broadcast. -n/--count <N>, --asset <hex|lbtc> (default lbtc), --amount-each <sats> (optional — splits balance evenly if omitted), --wallet, --esplora, --data-dir.

config [key] [value]

With no args, print config. With key value, set it. Valid keys: default_network (testnet|mainnet), default_esplora (URL).

Typical session

txw config default_network testnet
txw config default_esplora https://blockstream.info/liquidtestnet/api
txw create-wallet --out wallet.json
txw info --wallet wallet.json          # → fund this address
txw sync --wallet wallet.json
txw prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json
txw run examples/p2pk/txmanifest.json Pay --network testnet --wallet wallet.json

Wallet implementation guide

Audience: Wallet implementors consuming manifests to build and sign transactions.

The tx-manifest-wallet CLI used throughout this book is an example implementation of the lifecycle described here. Any wallet can consume a manifest by following the same steps. This page describes the execution lifecycle a wallet follows when executing an action from a manifest. Field definitions are not duplicated here; refer to Spec.md for the authoritative field reference.


Lifecycle

The following steps are executed in order for each action execution.

1. Parse

Read compile_params.user_provided and the target action's params and args. Determine which values the user must supply upfront. Values already fixed by provided_inputs.params are excluded from user prompting.

2. User inputs args and params

Prompt the user for all required args and params values not already covered by provided_inputs. Present description fields as guidance text.

3. Input selection

For each input in the action's inputs array, attempt to auto-select a UTXO satisfying the input's utxo_source, asset, and amount_sat constraints. Inputs already fixed by provided_inputs.inputs are used verbatim — do not prompt for these.

  • For ambiguous cases (multiple candidates) or when auto-select is disabled by wallet policy, prompt the user to choose.
  • User may opt into auto-select depending on wallet implementation.
  • Validate each provided_inputs UTXO against chain state: confirm it is unspent and its script_pubkey matches the expected script.

4. on_input_resolved hooks run

Execute all hooks declared in hooks.on_input_resolved, in declaration order (the order they appear in the file). This is the only ordering guarantee.

Each hook is keyed by input id and runs a SimplicityHL program that sets one or more compile_params.DERIVED_PARAM values. The execution context available to each hook:

  • Resolved input outpoints (txid, vout), amounts, and assets for all inputs resolved so far.
  • All compile_params set to date, including values set by earlier hooks in the same pass.

All hooks must complete before any validation runs. Subsequent validations and output formulas may depend on the derived params set here.

On-chain context jets (current_index, input_script_hash, etc.) are not available at build time. Those jets execute only during on-chain script evaluation, not during transaction construction.

5. Outputs constructed

Build the transaction outputs from the action's outputs array. Evaluate each amount_sat formula using the now-complete compile_params context (user-provided plus all hook-derived values), resolved input amounts, and action args/params. Resolve output destination fields to concrete scriptPubKeys.

6. Fee rate chosen and applied

Estimate the transaction fee or prompt the user for a fee rate. Apply the fee to the transaction, adjusting any "change" output accordingly.

7. on_validate hook runs (if present)

If the action declares an on_validate hook, run the full SimplicityHL program against the current transaction state. The program returns Option<u16>:

  • None — validation passes; continue.
  • Some(n) — validation fails with error code n; look up n in the top-level errors map and surface the description to the user. Flow returns to step 3.

8. 1-liner validations run

Execute each entry in the action's validations array, in declaration order. Each validation evaluates its rule against the current transaction state.

  • A failing arithmetic or simplicity_hl validation produces an error code from the entry's error.code field.
  • A failing utxo_exists validation produces the same.

9. On any validation error

Look up the error code (string key) in the top-level errors map to obtain the English-language description. Surface this to the user. The user adjusts their inputs or params and flow returns to step 3.

10. Fee review / adjustment → PSET created

Present the user with the final fee amount. If the user adjusts the fee rate, rerun from step 6. Signatures are not yet present at this point, so there is no witness-invalidation problem.

Once the user confirms, construct the PSET. This is the boundary between manifest-level reasoning and standard Elements/Bitcoin wallet machinery. A wallet that does not implement tx-manifest can receive the PSET from this point onwards and handle signing and broadcast normally.

11. Wallet signs

Populate witnesses into the PSET per the action's witnesses map. For each witness descriptor, produce the required data (signatures, preimages, SimplicityHL-typed values, etc.) as specified by the source type. Pre-computed witnesses from provided_inputs.witnesses are included verbatim.

12. Simplicity dry-run

Execute the covenant scripts on all inputs against the signed PSET. This is a local simulation of on-chain script execution; it does not broadcast.

A dry-run failure indicates a bug in the manifest or wallet implementation, not a user error. Surface it as an internal error with the relevant input index and script. Do not ask the user to retry.

This step is distinct from the manifest validations in steps 7–8. Manifest validations are pre-flight business-logic checks expressible without a full Simplicity interpreter. The dry-run is the final cryptographic and covenantal correctness check, confirming that the on-chain scripts will accept the constructed transaction.

13. Broadcast

Finalise and extract the transaction from the PSET. Broadcast to the network, or hand off to an external broadcast service.


Execution context for SimplicityHL code

The following are available to all SimplicityHL code at build time (hooks and validations):

AvailableDescription
Resolved input outpointstxid and vout for each resolved input
Resolved input amounts and assetsamount_sat and asset for each resolved input
compile_paramsAll user-provided values plus any values set by hooks that have already run
Action args and paramsRuntime values supplied by the user

The following are not available at build time:

Not availableReason
current_index, input_script_hash, and other introspection jetsThese are on-chain execution context — they only exist when a Simplicity program runs inside the node during transaction validation, not during wallet-side transaction construction.

Error codes

Error codes are u16 values. The manifest's top-level errors field maps numeric codes to English-language descriptions:

"errors": {
  "1001": "Collateral amount is below the minimum required for this loan.",
  "1002": "Loan has not yet expired; liquidation is not permitted."
}

Both on_validate (step 7) and per-entry validations (step 8) produce error codes. The wallet looks up the code in errors and displays the description to the user.

Localisation. Other locales are provided as separate JSON files sharing the same numeric keys — the manifest itself carries only the English descriptions. Wallet implementations that support multiple locales load the appropriate locale file and index into it by the same code.


Notes on provided_inputs

When a manifest arrives with a provided_inputs section (e.g. from a DEX front-end or counterparty):

  • Treat every entry in provided_inputs.inputs as fixed — do not prompt the user to select these UTXOs.
  • Treat every entry in provided_inputs.params as fixed — do not prompt the user for these values.
  • Include every entry in provided_inputs.witnesses verbatim in the PSET — do not re-derive or overwrite.
  • Validate all provided UTXOs against chain state before proceeding (step 3).
  • Validate pre-computed witnesses cryptographically before including them (step 11).

provided_inputs data arrives from an untrusted source. See Spec.md Section 17 for the full security requirements.

Glossary

Action / Method — A single transaction recipe in a manifest: its inputs, outputs, witnesses, and validations. Action (top-level) and method (inside a class) are structurally identical.

Attestation — A BIP340 signature over the finalized manifest by a developer, auditor, or counterparty. Tampering invalidates it.

Class — A typed contract definition with named fields and methods. Each deployed instance of a class has its own instance file.

CMR (Commitment Merkle Root) — The 32-byte hash of a compiled Simplicity program. Doubles as the program's on-chain identity.

canonical_cmr — The CMR of a program with all parameters zeroed. A stable identifier for the program's structure, independent of instance parameters.

Compile param — A value baked into a covenant script at deploy time. Changing one changes the script's address. Stored in the instance file.

Manifest — The static JSON protocol definition (txmanifest.json).

Covenant — A script that constrains how its output may be spent — e.g. by introspecting the spending transaction's inputs and outputs.

Derived param — A compile param computed by the tool rather than supplied: from a formula, or from the outpoint of an issuance input.

Instance file — Per-deployment compile params and class field values (<name>.instance.json).

NUMS point — "Nothing Up My Sleeve" — a public key with no known private key, used as a Taproot internal key to make the key-path provably unspendable.

provided_inputs — UTXOs pre-filled inline in the instance file, letting a wallet spend a counterparty's output it never indexed.

PSET — Partially Signed Elements Transaction (the Elements equivalent of a PSBT).

State file — The live on-chain UTXO set for one instance (<name>.state.json), updated after every broadcast.

Tapleaf compute spec — A field value (lang: "tapleaf") that compiles a .simf file with params to produce a covenant script hash.

UTXO type — A named on-chain state with a known script, so a wallet can recognise the protocol's outputs.

Witness — A value supplied to satisfy a Simplicity program when spending: a signature, a path selector, a leaf selector, or a computed value.