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

Everything a wallet needs to build the protocol's transactions without reading the covenant source: the contract types and their compile-time parameters, the on-chain states those contracts can create, the valid transactions between them, and the state machine tying it together.

Anatomy of a manifest dissects each section in turn. But first, let's get the tooling ready.

Setup

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

tx-manifest-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 }
  ],
  "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 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, 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

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:

  1. 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.
  2. You capture that ID with an on_resolved hook 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 }
}
FieldRequiredPurpose
kindyes"new" for a first issuance, "reissue" to mint more of an existing asset.
asset_amount_satyesHow many units to issue, in the asset's base denomination. A literal or a formula.
inflation_amount_satnoInflation (reissuance-token) amount. 0 disables reissuance — the supply is fixed forever.

The input is still an ordinary input: it's a wallet UTXO you also spend for its L-BTC (here, to pay the fee). The issuance block just rides along on it.

Why the asset ID comes from the outpoint

A Liquid asset ID is computed from the outpoint (txid + vout) of the input that issues it. That makes the ID globally unique without a registry — no two inputs can ever share an outpoint — but it has a practical consequence:

One issuance per input, and the ID isn't known up front. To mint N distinct assets in one transaction you need N distinct input UTXOs. And because the ID depends on which UTXO the wallet picks, you can't hard-code it — you compute it during the build and capture it (next section).

This is why the lending protocol ships a Prepare helper action that splits one wallet UTXO into four before IssueUtilityNFTs runs: four NFTs need four separate inputs to issue from.

Capturing the new asset ID with on_resolved

Once the build picks the input's UTXO, its outpoint — and therefore the new asset ID — is fixed. An on_resolved hook on the input fires at that moment and lets you stash the ID into a compile param:

{
  "id": "nft_issuance_input",
  "utxo_source": "wallet",
  "asset": "lbtc",
  "issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 },
  "on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } }
}

Inside an input's own on_resolved, the bare word asset means this input's resolved asset ID — the freshly minted one. Elsewhere in the action you refer to it by the input's id, as nft_issuance_input.asset (the general formula reference form). From here on, compile_params.BORROWER_NFT_ASSET_ID is a normal param: you can lock outputs to it, feed it into a covenant's compile params, or write it into the instance file.

Hooks recap. on_resolved runs per-input as soon as that input's UTXO is known; on_pre_broadcast runs once per action just before building. Both run set assignments. See Hooks & tapleaf compute.

Run it

Issuance has no standalone example manifest — it's exercised by the lending constructor. IssueUtilityNFTs needs four separate L-BTC UTXOs (one per issuance), which Prepare carves out first:

txw run examples/lending/txmanifest.json Prepare \
  --wallet borrower.json
txw run examples/lending/txmanifest.json IssueUtilityNFTs \
  --wallet borrower.json
txw sync --wallet borrower.json
txw get-balance --wallet borrower.json   # four new single-asset balances

After it broadcasts, the wallet holds four newly minted assets and the instance file records their IDs. To see the transaction's outputs (including the issuance outputs) before broadcasting, add --export-pset issue.pset.json and decode it.

Try next

You've minted assets and captured their derived IDs. The full four-NFT construction — plus packing loan terms into amounts and computing the covenants those NFTs get locked to — is the first phase of the lending walkthrough: Issuing the NFTs & encoding the terms. The hooks that capture and derive these values get their own recipe: Hooks & tapleaf compute.

Hooks & tapleaf compute

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

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

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

  • Hook blocks: on_resolved (per input) 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 (compute: "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.

Problem. Two strangers want to transact a collateralised loan with no escrow agent and no trusted backend. A borrower locks collateral and advertises terms; a lender supplies the principal; the loan later settles by repayment or — if the borrower defaults — by liquidation after a deadline. Every rule is enforced on-chain by Simplicity covenants.

This is the capstone of the cookbook. Everything the recipes introduced one at a time — covenant UTXO types, multiple spending paths, asset issuance & NFTs, formulas & derived params, hooks & tapleaf compute, and the class / instance model — shows up here at once, wired into a single working protocol.

The full example lives in the repository at examples/lending/: one txmanifest.json plus five .simf covenant programs. This chapter walks through it in the order you'd actually run it.

The deal, in one paragraph

Alice has collateral (say, L-BTC) and wants to borrow L-USDT against it without selling. She locks her collateral into a covenant and publishes her terms — amount, interest, expiry — as on-chain NFTs anyone can read. Bob sees the offer, likes the terms, and accepts by sending Alice the principal; in the same transaction her collateral moves into a second covenant that holds it for the life of the loan. To get her collateral back, Alice repays principal plus interest before the deadline. If she doesn't, Bob can seize the collateral once the deadline passes. At no point does either party have to trust the other or any third party — the covenants only permit the honest transitions.

The cast

Two roles. The protocol has a borrower and a lender. They are different people with different wallets, so when you run it you'll keep two wallet files — borrower.json and lender.json — and run each action as the appropriate party.

Five covenant programs. Each .simf file is a small Simplicity program that gates one kind of UTXO. They are deliberately tiny and composable:

ProgramRole
pre_lock.simfHolds 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.simfHolds the collateral during the active loan. PATH::LEFT is repayment; PATH::RIGHT is liquidation after expiry.
script_auth.simfWraps 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.simfGuards the lender's principal vault: release requires burning the Lender NFT.
p2pk.simfThe 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:

NFTCarriesUsed for
Borrower NFTnothing (amount = 1)proves a transaction is the borrower's; co-spent in setup and repayment
Lender NFTnothing (amount = 1)the lender's bearer token; needed to liquidate and to drain the vault
First Parameters NFTinterest rate, expiry, decimals — bit-packed into its amountpublishes the loan terms; checked by every covenant
Second Parameters NFTcollateral & principal base amounts — bit-packed into its amountpublishes the amounts; checked by every covenant

The lifecycle

The whole protocol is one lending_contract class, and the manifest closes with an optional lifecycle block that names the state machine its methods walk through:

"lifecycle": {
  "states": ["nfts_issued", "offer_open", "loan_active", "repaid", "liquidated", "cancelled"],
  "entry_actions": ["IssueUtilityNFTs"],
  "transitions": {
    "IssueUtilityNFTs":           { "to": "nfts_issued" },
    "LockCollateral":             { "from": "nfts_issued", "to": "offer_open" },
    "CancelOffer":                { "from": "offer_open",  "to": "cancelled",  "unilateral": true },
    "SetupLending":               { "from": "offer_open",  "to": "loan_active" },
    "RepayLoan":                  { "from": "loan_active", "to": "repaid",     "cooperative": true },
    "LiquidateAfterExpiry":       { "from": "loan_active", "to": "liquidated", "unilateral": true },
    "ClaimPrincipalWithInterest": { "from": "repaid",      "to": "settled" }
  }
}

lifecycle is documentation-only. The spec lists it among the top-level fields as "named states, transitions, execution paths" and marks it purely informative — nothing on-chain depends on it, and no tool is required to enforce it. It exists so a reader (or a diagram renderer) can see the intended state machine at a glance without tracing every method's inputs and outputs. It may be dropped from a future revision; treat it as a map, not machinery.

The block has three parts:

  • states — the named states an instance can be in. They're free-form labels; the from/to fields below refer to them. (settled appears as a to target without being listed — a reminder that this section is descriptive, not validated.)
  • entry_actions — the methods that create a fresh instance rather than advancing an existing one. Here it's the constructor, IssueUtilityNFTs.
  • transitions — one entry per method, each naming the state it moves from and to, plus two optional flags described below. A transition with no from (the constructor) is an entry point.

Rendered, those transitions are the protocol's flow:

                    IssueUtilityNFTs
                          │   (borrower mints 4 NFTs + computes covenant hashes)
                          ▼
                    ┌───────────┐
                    │ nfts_issued│
                    └───────────┘
                          │   LockCollateral  (borrower)
                          ▼
                    ┌───────────┐   CancelOffer (borrower, unilateral)
                    │ offer_open │ ─────────────────────────────►  cancelled
                    └───────────┘
                          │   SetupLending  (lender accepts)
                          ▼
                    ┌───────────┐
                    │loan_active │
                    └───────────┘
                       │        │
        RepayLoan      │        │   LiquidateAfterExpiry
     (borrower,        │        │   (lender, unilateral,
      cooperative)     ▼        ▼    after LOAN_EXPIRATION_TIME)
                 ┌────────┐  ┌───────────┐
                 │ repaid │  │ liquidated│
                 └────────┘  └───────────┘
                       │
        ClaimPrincipalWithInterest (lender drains the vault)
                       ▼
                    settled

The two optional flags annotate who a transition needs. "unilateral": true marks the two escape hatches — CancelOffer and LiquidateAfterExpiry — that one party can take without the other's cooperation; that's the whole point of a trustless protocol, the exits don't depend on the counterparty playing along. "cooperative": true marks RepayLoan as the happy path both sides want. The flags don't do anything on-chain — the covenants are what actually enforce who can spend — but they tell a reader at a glance which transitions are adversarial and which are mutual.

How this chapter is organised

The walkthrough follows the lifecycle across four pages, each building one phase and pulling in the recipes that introduced its pieces:

  1. Issuing the NFTs & encoding the terms — the IssueUtilityNFTs constructor: 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.
  2. Opening the offerLockCollateral puts the collateral and NFTs on-chain behind the pre_lock covenant, with an op_return discovery beacon and a pre-build validations check.
  3. Accepting or cancelling the offer — the two spending paths of pre_lock: the lender's SetupLending (with the required_index discipline a covenant demands) versus the borrower's CancelOffer.
  4. Settling: repay, liquidate, withdraw — the borrower's RepayLoan versus the lender's LiquidateAfterExpiry on the lending covenant, then draining the principal vault with ClaimPrincipalWithInterest.

Before you run it

The CLI is tx-manifest-wallet, aliased throughout the book to txw. Do the one-time setup first. Because this protocol has two roles, create two wallets and fund both from the testnet faucet:

txw create-wallet --out borrower.json
txw create-wallet --out lender.json
# fund each from https://liquidtestnet.com/faucet, then:
txw sync --wallet borrower.json
txw sync --wallet lender.json

Where's the funding address? Run info on 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.json

See Fund and sync for the full walk through.

Get oriented with describe and validate before building anything — describe prints the classes, methods, and lifecycle; validate checks the manifest is internally consistent:

txw describe examples/lending/txmanifest.json
txw validate examples/lending/txmanifest.json

Then start with Issuing the NFTs.

This is the most involved example in the book. If you haven't worked through Hello World and the Last Will covenant yet, do those first — they introduce the single-key and multi-path patterns this protocol composes at scale.

Issuing the NFTs & encoding the terms

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

Phase 1 of the lending walkthrough. The borrower constructs a loan offer: mint four NFTs, pack the loan terms into two of them, compute the covenant addresses the rest of the protocol locks to, and write it all into an instance file.

Nothing is on-chain as a loan yet after this step — IssueUtilityNFTs only mints the tokens and records the parameters. But it's the densest action in the protocol, because it's where four cookbook ideas converge:

  • Issuance — minting new Liquid assets whose IDs come from input outpoints (recipe 8).
  • Bit-packing — encoding the loan terms into NFT amount fields so the covenants can read them on-chain (recipe 7).
  • Tapleaf compute — deriving each covenant's script hash by compiling a .simf, where some hashes feed into others (recipe 9).
  • A constructorcreate_instance persists the whole deal to a file so every later method can read it back (recipe 10).

The constructor and its terms

IssueUtilityNFTs is the class's is_constructor method. Its params are the loan terms the borrower chooses:

"IssueUtilityNFTs": {
  "is_constructor": true,
  "params": {
    "BORROWER_PUB_KEY":            { "type": "pubkey", "source": { "type": "wallet_key" } },
    "COLLATERAL_ASSET_ID":         { "type": "liquid.asset_id" },
    "COLLATERAL_AMOUNT":           { "type": "u64" },
    "COLLATERAL_DECIMALS_MANTISSA":{ "type": "u8", "default": "8" },
    "PRINCIPAL_ASSET_ID":          { "type": "liquid.asset_id" },
    "PRINCIPAL_AMOUNT":            { "type": "u64" },
    "PRINCIPAL_DECIMALS_MANTISSA": { "type": "u8" },
    "PRINCIPAL_INTEREST_RATE":     { "type": "u16" },
    "LOAN_EXPIRATION_TIME":        { "type": "u32" }
  },
  ...
}

BORROWER_PUB_KEY auto-fills from the borrower's wallet signing key (the wallet_key source from Parameters). The interest rate is in basis points (u16, so 10,000 = 100%); the expiry is a block height (CLTV). The two DECIMALS_MANTISSA values matter for the encoding below — they let a base-10 amount like "1 L-BTC" be stored compactly as 1 with a separate exponent of 8.

Minting four NFTs from issuance inputs

New to issuance? This is the first action in the book that mints assets. Asset issuance & NFTs covers the issuance block, why asset IDs come from outpoints, and bearer-token NFTs in isolation — read it first if any of the below is unfamiliar.

A Liquid asset ID is derived from the outpoint of the input that issues it, so to mint four distinct NFTs you need four distinct wallet UTXOs. That's why the helper Prepare action splits one UTXO into four beforehand. Each issuance input declares an issuance block and captures the resulting asset ID with an on_resolved hook:

{
  "id": "borrower_nft_issuance_input",
  "utxo_source": "wallet",
  "asset": "lbtc",
  "issuance": { "kind": "new", "asset_amount_sat": 1, "inflation_amount_sat": 0 },
  "on_resolved": { "set": { "compile_params.BORROWER_NFT_ASSET_ID": "asset" } }
}

The Borrower and Lender NFTs are issued with asset_amount_sat: 1 — true single-unit bearer tokens. The two Parameter NFTs are different: their issued amount is not 1 but the encoded loan terms (next section), so the asset's very supply carries the offer:

{
  "id": "first_params_issuance_input",
  "utxo_source": "wallet",
  "asset": "lbtc",
  "issuance": { "kind": "new", "asset_amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED", "inflation_amount_sat": 0 },
  "on_resolved": { "set": { "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" } }
}

All four asset IDs land in compile_params.* via on_resolved, ready for the covenant-hash computation. The matching outputs simply send each freshly minted NFT back to the borrower's wallet. See Asset issuance & NFTs for the issuance mechanics in isolation.

Bit-packing the loan terms

Here's the trick that makes the offer self-describing on-chain. Rather than store the terms off-chain, the protocol packs them into the amount fields of the two Parameter NFTs, computed in an on_pre_broadcast hook before the transaction is built:

"on_pre_broadcast": {
  "set": {
    "compile_params.FIRST_PARAMETERS_ENCODED":
      "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328",
    "compile_params.SECOND_PARAMETERS_ENCODED":
      "params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432"
  }
}

Each multiplier is a power of two — it's a left-shift dressed up as multiplication. The First Parameters amount packs four fields into a 64-bit integer:

FieldBitsWidthMultiplier
PRINCIPAL_INTEREST_RATE0–1516×1
LOAN_EXPIRATION_TIME16–4227×65536 (2¹⁶)
COLLATERAL_DECIMALS_MANTISSA43–464×8796093022208 (2⁴³)
PRINCIPAL_DECIMALS_MANTISSA47–504×140737488355328 (2⁴⁷)

The Second Parameters amount packs the two base amounts — each divided down by its decimal exponent so it fits in 25 bits:

FieldBitsWidthMultiplier
COLLATERAL_AMOUNT / 10^collateral_decimals0–2425×1
PRINCIPAL_AMOUNT / 10^principal_decimals25–4925×33554432 (2²⁵)

This is exactly the layout the covenants unpack. In pre_lock.simf and lending.simf, extract_lending_parameters masks and shifts these same bit ranges back out, then validate_lending_params asserts they match the covenant's own compile params:

#![allow(unused)]
fn main() {
let (interest_rate_raw, shift): (u64, u8) =
    extract_bits_from_amount(first_parameters_amount, 16, 0);   // bits 0–15
let (loan_expiration_time_raw, shift): (u64, u8) =
    extract_bits_from_amount(first_parameters_amount, 27, shift); // bits 16–42
// …decimals…  then from the second NFT, two 25-bit base amounts
}

The manifest's packing and the covenant's unpacking are two halves of one wire format — get the widths or multipliers out of sync and validate_lending_params aborts the spend. That mutual dependence is the whole reason the encoding lives in the example as a worked reference rather than something you'd reinvent.

Why pack at all? A covenant can only introspect what's in the transaction. By making the terms the NFTs' amounts, every spend that moves the NFTs carries the terms with it, and each covenant re-derives and re-checks them — no oracle, no side channel. The cost is the 64-bit budget you're packing into, which is why base amounts are stored with a separate decimals exponent.

Computing the covenant addresses

The borrower has to lock collateral to the pre_lock covenant — but pre_lock's address depends on the lending covenant's hash, which depends on the principal vault's hash, which depends on the Lender NFT's asset ID, which only exists after the issuance inputs resolve. create_instance untangles this with a set of tapleaf compute fields, each compiling a .simf to its script hash:

"create_instance": {
  "class": "lending_contract",
  "fields": {
    "PRINCIPAL_OUTPUT_SCRIPT_HASH": {
      "compute": "tapleaf", "simf": "./p2pk.simf",
      "params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } }
    },
    "LENDER_PRINCIPAL_COV_HASH": {
      "compute": "tapleaf", "simf": "./asset_auth.simf",
      "params": {
        "ASSET_ID":        { "type": "liquid.asset_id", "value": "LENDER_NFT_ASSET_ID" },
        "ASSET_AMOUNT":    { "type": "u64",  "value": "1" },
        "WITH_ASSET_BURN": { "type": "bool", "value": "true" }
      }
    },
    "LENDING_COV_HASH": {
      "compute": "tapleaf", "simf": "./lending.simf",
      "params": { "…": "…", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } }
    },
    "PRE_LOCK_COV_HASH": {
      "compute": "tapleaf", "simf": "./pre_lock.simf",
      "params": { "…": "…", "LENDING_COV_HASH": { "type": "bytes32", "value": "LENDING_COV_HASH" } }
    },
    "…": "…"
  }
}

Read the value strings as references to other fields already computed. The dependency order forms a chain, not a cycle:

  p2pk.simf ───────────────► PRINCIPAL_OUTPUT_SCRIPT_HASH ─┐
  asset_auth.simf ─────────► LENDER_PRINCIPAL_COV_HASH ─┐  │
                                                        ▼  │
  lending.simf  ──────────►  LENDING_COV_HASH ──┬──────────┤
                                                │          │
  script_auth.simf(LENDING) ► PARAMETERS_NFT_OUTPUT_SCRIPT_HASH,
                              BORROWER_NFT_OUTPUT_SCRIPT_HASH ─┐
                                                               ▼
  pre_lock.simf ───────────► PRE_LOCK_COV_HASH ◄───────────────┘
  script_auth.simf(PRE_LOCK) ► PRELOCK_PARAMETERS_NFT_SCRIPT_HASH

The tool compiles them in dependency order: leaf programs first (p2pk, asset_auth), then lending (which needs the vault hash), then the script_auth wrappers keyed to LENDING_COV_HASH, and finally pre_lock (which needs all of the above) and its own script_auth wrapper.

script_auth.simf compiled twice. The same program appears as two distinct UTXO types — prelock_script_auth and lending_script_auth — because it's compiled with two different SCRIPT_HASH params. One wraps the NFTs to the pre_lock covenant during the offer; the other re-wraps them to the lending covenant once the loan is active. Same code, two addresses. The tapleaf compute recipe covers this pattern; covenant UTXO types covers why a .simf plus its params is an address.

One derived field: the interest amount

Most instance fields are either passthrough ("$params.X") or tapleaf hashes. One is a plain arithmetic derived param:

"PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000"

The borrower never enters the interest amount — only the rate. The actual satoshis owed are computed once, here, and stored in the instance so RepayLoan can require exactly principal + interest later. (The lending covenant computes the same figure on-chain in calculate_interest, so the two agree.)

What you end up with

After broadcast, create_instance writes lending.instance.json next to the manifest, holding every field above: the four asset IDs, the four covenant hashes, the packed parameter values, the interest amount, and the borrower's key. This file is the deal. Every later method — LockCollateral, SetupLending, RepayLoan, and the rest — is run with --instance lending.instance.json so the wallet rebuilds the exact same covenant addresses without re-entering anything. This is the full class / instance model from Instance, state & constructors.

Run it

IssueUtilityNFTs needs four separate L-BTC UTXOs (one per issuance). Prepare splits one into four; then run the constructor as the borrower:

# split one wallet UTXO into four for the four issuances
txw run examples/lending/txmanifest.json Prepare \
  --wallet borrower.json

A testnet params file

IssueUtilityNFTs would otherwise prompt for every loan term. Drop a params file next to the manifest and the tool auto-discovers it — for testnet it looks for txmanifest.testnet.json in examples/lending/. Here's a complete one for a single-asset L-BTC loan (borrow testnet L-BTC against testnet L-BTC), so the faucet can fund both the borrower and the lender:

{
  "COLLATERAL_ASSET_ID":          "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
  "COLLATERAL_AMOUNT":            "200000",
  "COLLATERAL_DECIMALS_MANTISSA": "0",
  "PRINCIPAL_ASSET_ID":           "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
  "PRINCIPAL_AMOUNT":             "100000",
  "PRINCIPAL_DECIMALS_MANTISSA":  "0",
  "PRINCIPAL_INTEREST_RATE":      "1000",
  "LOAN_EXPIRATION_TIME":         "5000000"
}

That id is testnet L-BTC (the same asset the faucet dispenses). The terms describe a 0.002 L-BTC collateral loan for 0.001 L-BTC principal at 10% interest (1000 basis points). A few values are worth understanding rather than copying blindly:

  • BORROWER_PUB_KEY is absent on purpose — it's a wallet_key source, so it auto-fills from the borrower wallet. The file only carries the terms you choose.
  • *_DECIMALS_MANTISSA is 0 here, not 8. Recall from bit-packing that each amount is stored as amount / 10^decimals in a 25-bit base field, and the covenant rebuilds it as base × 10^decimals. So the amount must be an exact multiple of 10^decimals and the base must fit in 25 bits (< ~33.5M). With decimals = 0 the 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 of 10^8 (≥ 1 L-BTC), which the faucet won't cover.
  • LOAN_EXPIRATION_TIME is a block height — set it comfortably ahead of the current testnet tip (check an explorer; 5000000 is a placeholder). It only matters at liquidation; the constructor just records it.

With the file in place, the constructor reads every value from it — nothing to type:

# mint the NFTs, encode the terms, compute hashes, write the instance file
txw run examples/lending/txmanifest.json IssueUtilityNFTs \
  --network testnet --wallet borrower.json

On success the four NFTs are in the borrower's wallet and examples/lending/lending.instance.json exists. Sync, and confirm the NFTs:

txw sync --wallet borrower.json
txw get-balance --wallet borrower.json   # four new single-asset balances

Inspect the instance file

Open examples/lending/lending.instance.json to see what the constructor recorded — this is what every later method reads back:

{
  "instance": {
    "class": "lending_contract",
    "fields": {
      "BORROWER_NFT_ASSET_ID": "f94aff7f54bd4f4076a0aa07635264a32926966e119dc523ac86427d1f2239f7",
      "BORROWER_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
      "BORROWER_PUB_KEY": "0b9fa04ada4fcaa83b148ae76fee98fa1bd3a84a1eefe42295e0b98e1fbcac72",
      "COLLATERAL_AMOUNT": "3452",
      "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
      "COLLATERAL_DECIMALS_MANTISSA": "0",
      "FIRST_PARAMETERS_ENCODED": "327680000100",
      "FIRST_PARAMETERS_NFT_ASSET_ID": "c59e58652d6a00ba53e2ef97556210b106aff996fd76a8de8d04bcbef6888775",
      "LENDER_NFT_ASSET_ID": "0d51f8bcf2f6fe4e5c6ebc886a7cc87323e8599c5652ddb44bb6ac6c2e690d52",
      "LENDER_PRINCIPAL_COV_HASH": "279a4424550ccc694525388d9c461166a1454defadff4204d1a0885bd5ca2b83",
      "LENDING_COV_HASH": "135596e46be4b2a229ae25fea585b056e5b909e0aaee87fd6fc785c9201dd6cf",
      "LOAN_EXPIRATION_TIME": "5000000",
      "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": "c4b8e6299c4924f9375650c24457a3cb6c69c54cf66afcd0f8b667146ce55667",
      "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": "761c8268a5998d892c41df708dd64c18047b54750a760861af88e68336c1cfea",
      "PRE_LOCK_COV_HASH": "4ac452b2c2c79b932d74f7fee114106328ce18d68ad92091ed345c6c22f59f07",
      "PRINCIPAL_AMOUNT": "1000",
      "PRINCIPAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49",
      "PRINCIPAL_DECIMALS_MANTISSA": "0",
      "PRINCIPAL_INTEREST_AMOUNT": "10",
      "PRINCIPAL_INTEREST_RATE": "100",
      "PRINCIPAL_OUTPUT_SCRIPT_HASH": "b4966bd0290ef509e7b1a98dd0b3fe54bd1860f9bb163bf3acb3d3a8401d41d4",
      "SECOND_PARAMETERS_ENCODED": "33554435452",
      "SECOND_PARAMETERS_NFT_ASSET_ID": "5a001563b2384198a09127c3ac6f36d0cc2a980000fd9915c3ca3cc2d6e2c136"
    }
  }
}

What to look at:

  • The four *_ASSET_ID fields 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_HASH wrappers) are the addresses the next steps lock to — computed from those asset IDs and your BORROWER_PUB_KEY.
  • FIRST_PARAMETERS_ENCODED / SECOND_PARAMETERS_ENCODED are the bit-packed terms — and also the amounts your two Parameter NFTs were issued with. You can check them by hand: with the decimal fields 0, FIRST = INTEREST_RATE + EXPIRY × 65536 = 100 + 5000000 × 65536 = 327680000100, and SECOND = 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: LockCollateral moves the collateral and all four NFTs into covenant UTXOs, advancing the contract from nfts_issued to offer_open.

After issuing the NFTs the borrower holds four tokens and an instance file, but the collateral is still loose in their wallet. This phase commits it — the protocol's first move of value into covenant addresses, and the first use of the op_return destination and a pre-build validations check.

LockCollateral — publishing the offer

LockCollateral takes the collateral and all four NFTs from the borrower's wallet and moves them into covenant addresses. The collateral goes into the pre_lock UTXO; each NFT goes into a prelock_script_auth UTXO (the script_auth.simf wrapper compiled to PRE_LOCK_COV_HASH):

"LockCollateral": {
  "inputs": [
    { "id": "collateral_in",   "utxo_source": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID",
      "amount_sat": { "min_amount": "compile_params.COLLATERAL_AMOUNT" } },
    { "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_in",   "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID",   "amount_sat": 1 },
    { "id": "first_params_in",  "utxo_source": "wallet", "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" },
    { "id": "second_params_in", "utxo_source": "wallet", "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
  ],
  "outputs": [
    { "id": "pre_lock_out",       "destination": { "utxo_type": "pre_lock" },            "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
    { "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_locked",  "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.LENDER_NFT_ASSET_ID",   "amount_sat": 1 },
    { "id": "first_params_locked",  "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "compile_params.FIRST_PARAMETERS_ENCODED" },
    { "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "compile_params.SECOND_PARAMETERS_ENCODED" },
    { "id": "indexer_op_return", "destination": { "type": "op_return" },
      "data": "concat(compile_params.BORROWER_PUB_KEY, compile_params.PRINCIPAL_ASSET_ID)" },
    { "id": "collateral_change", "destination": "change", "asset": "compile_params.COLLATERAL_ASSET_ID", "optional": true },
    { "id": "fee_change",        "destination": "change", "asset": "lbtc", "optional": true }
  ],
  "validations": [
    { "id": "collateral_amount_matches",
      "rule": { "type": "arithmetic", "expr": "collateral_in.amount_sat == compile_params.COLLATERAL_AMOUNT" },
      "error": { "code": "AMOUNT_MISMATCH", "message": "Collateral input amount does not match COLLATERAL_AMOUNT" } }
  ]
}

Two things worth pausing on:

  • The OP_RETURN advertisement. indexer_op_return writes concat(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 the op_return destination; here it's used for discovery rather than burning.
  • The collateral amount is checked twice. The validations block asserts the input matches COLLATERAL_AMOUNT before building (a fast, friendly error), and the pre_lock covenant re-checks it on-chain at spend time. Validations are a convenience; the covenant is the law.

After this broadcasts, the contract is in offer_open: collateral and NFTs sit in covenant UTXOs that only the two pre_lock paths can move.

Run it

LockCollateral is run by the borrower, using the instance file written at construction:

# --- Borrower puts the offer on-chain ---
txw run examples/lending/txmanifest.json LockCollateral \
  --instance examples/lending/lending.instance.json --wallet borrower.json
txw sync --wallet borrower.json

With the collateral and NFTs locked behind pre_lock, the offer is live. Next, a lender accepts it — or the borrower withdraws it: Accepting or cancelling the offer.

Accepting or cancelling the offer

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

Phase 3 of the lending walkthrough. The offer is open. A lender accepts it with SetupLending, or the borrower withdraws it with CancelOffer — the two spending paths of the one pre_lock covenant.

Opening the offer left the collateral and NFTs behind the pre_lock covenant in state offer_open. That covenant can be spent two ways, and this phase is where multiple spending paths and the ScriptAuth wrapper carry real weight.

Two paths, selected by a witness

pre_lock accepts two spending paths, and the manifest exposes each as its own method: SetupLending takes the accept path, CancelOffer the cancel path. A method picks its path with a PATH witness — Left(()) or Right(()) — the simplicityhl selector from Multiple spending paths. How each path is enforced on-chain is out of scope here; what's new for the manifest are two witness patterns this phase relies on, both visible in the JSON below:

  • SPEND_PATH — a taproot_leaf witness on every covenant input, naming which tapleaf is being spent. Its value comes from a …_leaf formula (e.g. pre_lock_leaf).
  • INPUT_SCRIPT_INDEX — a simplicityhl witness on each NFT input. The NFTs live in prelock_script_auth UTXOs (a script_auth covenant type that must be co-spent with the collateral); the witness just tells that covenant which input the collateral is at — here, 0.

SetupLending — the lender accepts (PATH::LEFT)

The lender spends the pre_lock collateral via the accept path (PATH = Left(())), supplies the principal, and produces the active loan: collateral into the lending covenant, principal to the borrower, the NFTs re-wrapped under the lending-phase script_auth, and the Lender NFT to the lender's wallet.

The covenant requires those inputs and outputs in an exact order — and because the principal is injected at output 1, the NFT outputs sit one slot below their inputs. Rather than hope the builder lands on that layout, every input and output declares an explicit required_index:

"SetupLending": {
  "inputs": [
    { "id": "collateral_in",   "utxo_source": { "utxo_type": "pre_lock" }, "required_index": 0,
      "witnesses": { "PATH": { "type": "simplicityhl", "simplicity_type": "Either<()>", "value": "Left(())" },
                     "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "pre_lock_leaf" } } } },
    { "id": "first_params_in",  "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 1,
      "witnesses": { "INPUT_SCRIPT_INDEX": { "type": "simplicityhl", "simplicity_type": "u32", "value": "0" },
                     "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "prelock_script_auth_leaf" } } } },
    { "id": "second_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 2, "…": "…" },
    { "id": "borrower_nft_in",  "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 3, "…": "…" },
    { "id": "lender_nft_in",    "utxo_source": { "utxo_type": "prelock_script_auth" }, "required_index": 4, "…": "…" },
    { "id": "principal_in",     "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "required_index": 5,
      "amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT" } },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true, "required_index": 6 }
  ],
  "outputs": [
    { "id": "lending_collateral_out", "destination": { "utxo_type": "lending_collateral" },  "required_index": 0, "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
    { "id": "principal_to_borrower",  "destination": { "utxo_type": "p2pk" },                "required_index": 1, "asset": "compile_params.PRINCIPAL_ASSET_ID",  "amount_sat": "compile_params.PRINCIPAL_AMOUNT" },
    { "id": "first_params_relocked",  "destination": { "utxo_type": "lending_script_auth" }, "required_index": 2, "…": "…" },
    { "id": "second_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 3, "…": "…" },
    { "id": "borrower_nft_released",  "destination": { "utxo_type": "lending_script_auth" }, "required_index": 4, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_released",    "destination": "wallet",                                "required_index": 5, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "principal_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true, "required_index": -2 },
    { "id": "fee_change",       "destination": "change", "asset": "lbtc", "optional": true, "required_index": -1 }
  ]
}

Three details that make this work:

  • required_index is the contract between manifest and covenant. The covenant reads output_amount(2) and output_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 target lending_script_auth instead of prelock_script_auth — the same script_auth program compiled to LENDING_COV_HASH rather than PRE_LOCK_COV_HASH. A destination's utxo_type is all the manifest needs to move the tokens from the offer-phase covenant to the active-loan one.

CancelOffer — the borrower backs out (PATH::RIGHT)

If no lender accepts, the borrower reclaims the collateral and destroys the offer. CancelOffer takes the cancel path (PATH = Right(())), which the covenant gates with a borrower signature — so this method adds a SIGNATURE witness sourced from BORROWER_PUB_KEY — and routes every NFT to an op_return to burn it. Here the outputs line up one-to-one with the inputs (no principal is injected), so no required_index is needed; the collateral returns to the borrower's wallet:

"CancelOffer": {
  "inputs": [
    { "id": "pre_lock_in", "utxo_source": { "utxo_type": "pre_lock" },
      "witnesses": { "PATH": { "type": "simplicityhl", "value": "Right(())" },
                     "SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
                                    "source": { "type": "wallet", "key": "compile_params.BORROWER_PUB_KEY" } },
                     "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "pre_lock_leaf" } } } },
    { "id": "first_params_in", "utxo_source": { "utxo_type": "prelock_script_auth" }, "…": "…" },
    "…borrower & lender NFTs…",
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
  ],
  "outputs": [
    { "id": "collateral_returned", "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "pre_lock_in.amount_sat" },
    { "id": "first_params_burned",  "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "first_params_in.amount_sat" },
    { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
    { "id": "borrower_nft_burned",  "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_burned",    "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID",   "amount_sat": 1 },
    { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

CancelOffer is unilateral in the lifecycle — the borrower needs no one's cooperation, exactly as a trustless escape hatch should be. It mirrors the cold-key break-out from the Last Will: a signature-gated path that ends the contract.

Test the cancel path too. The happy flow runs SetupLending; CancelOffer exercises the other pre_lock branch. 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. SetupLending is 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 the OP_RETURN beacon); here, share the file between the two wallets.

Once SetupLending broadcasts, the loan is loan_active: the borrower has the principal and the collateral is held by the lending covenant. On to settling the loan.

Settling: repay, liquidate, withdraw

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

Phase 4 of the lending walkthrough. The loan is active. It ends one of two ways — the borrower repays and reclaims the collateral, or the lender liquidates it after the deadline — and the lender finally withdraws their principal plus interest from a vault.

By now the collateral sits behind the lending covenant, the borrower holds the principal, and the lender holds the Lender NFT. This phase resolves the loan. It reuses the two-path covenant shape from accepting the offer, adds an absolute timelock, and introduces the last piece — the asset_auth.simf principal vault.

A detour: ClaimLoanFunds

SetupLending delivered the principal to the borrower's p2pk address — a covenant output, not a plain wallet UTXO. ClaimLoanFunds sweeps it into the wallet so the borrower can actually use it. It's the Hello World receive spend, unchanged: one p2pk input, a Schnorr signature, one wallet output.

"ClaimLoanFunds": {
  "inputs": [
    { "id": "principal_in", "utxo_source": { "utxo_type": "p2pk" },
      "witnesses": {
        "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "p2pk_leaf" } },
        "SIGNATURE":  { "type": "Signature", "sig_type": "sig_hash_all",
                        "source": { "type": "wallet", "key": "$params.BORROWER_PUB_KEY" } } } },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
  ],
  "outputs": [
    { "id": "principal_to_borrower", "destination": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" },
    { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

This is not a lifecycle transition — the loan is still loan_active whether or not the borrower has swept the principal. It's housekeeping, included to show that a covenant payout is just another UTXO you spend normally once it's yours.

Run it as the borrower. ClaimLoanFunds also needs --state so the tool knows which p2pk UTXO to claim — the live one the state file recorded when SetupLending paid the principal out:

txw run examples/lending/txmanifest.json ClaimLoanFunds \
  --instance examples/lending/lending.instance.json \
  --state examples/lending/lending.state.json \
  --wallet borrower.json
txw sync --wallet borrower.json

The lending covenant: repay or liquidate

lending.simf is the offer covenant's sibling — same Either<(), ()> PATH selector, different two outcomes:

fn main() {
    match witness::PATH {
        Left(params: ())  => { loan_repayment_path(); },   // borrower repays
        Right(params: ()) => { loan_liquidation_path(); }, // lender seizes after expiry
    }
}

PATH::LEFT is repayment — anyone can fund it, but it only succeeds if it routes principal + interest to the lender's vault. PATH::RIGHT is liquidation — gated by a timelock instead of a signature.

RepayLoan — borrower settles (PATH::LEFT)

The borrower returns principal plus interest and gets the collateral back. The covenant computes the interest on-chain (calculate_interest, the same basis-point math the instance stored as PRINCIPAL_INTEREST_AMOUNT) and demands the repayment land in the lender's vault:

#![allow(unused)]
fn main() {
fn loan_repayment_path() {
    assert!(jet::eq_32(jet::current_index(), 0));
    ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); // in0→out0 collateral back
    let first  = ensure_input_and_output_assets_eq(1, 2, param::FIRST_PARAMETERS_NFT_ASSET_ID);
    let second = ensure_input_and_output_assets_eq(2, 3, param::SECOND_PARAMETERS_NFT_ASSET_ID);
    ensure_input_and_output_assets_with_amount_eq(3, 4, param::BORROWER_NFT_ASSET_ID, 1);
    // …unpack & validate terms…
    let owed: u64 = calculate_principal_with_interest(principal_amount, interest_rate);
    ensure_asset_with_amount(1, false, param::PRINCIPAL_ASSET_ID, owed); // out1 = principal+interest
    ensure_script_hash(1, false, param::LENDER_PRINCIPAL_COV_HASH);      // …into the vault
    ensure_output_is_op_return(2);  // burn the params + borrower NFT
    ensure_output_is_op_return(3);
    ensure_output_is_op_return(4);
}
}

Same off-by-one as SetupLending: the repayment is injected at output 1, shifting the NFT outputs down. The collateral returns to the borrower's wallet (output 0), the Parameter and Borrower NFTs are burned (the loan is over), and the principal + interest goes to the vault — not directly to the lender. The manifest mirrors that layout:

"RepayLoan": {
  "inputs": [
    { "id": "lending_in", "utxo_source": { "utxo_type": "lending_collateral" },
      "witnesses": { "PATH": { "type": "simplicityhl", "value": "Left(())" },
                     "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lending_leaf" } } } },
    { "id": "first_params_in",  "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
    { "id": "second_params_in", "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
    { "id": "borrower_nft_in",  "utxo_source": { "utxo_type": "lending_script_auth" }, "…": "…" },
    { "id": "repayment_in", "utxo_source": "wallet", "asset": "compile_params.PRINCIPAL_ASSET_ID",
      "amount_sat": { "min_amount": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" } },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
  ],
  "outputs": [
    { "id": "collateral_returned",        "destination": "wallet", "asset": "compile_params.COLLATERAL_ASSET_ID", "amount_sat": "compile_params.COLLATERAL_AMOUNT" },
    { "id": "principal_interest_to_vault","destination": { "utxo_type": "lender_principal_vault" }, "asset": "compile_params.PRINCIPAL_ASSET_ID",
      "amount_sat": "compile_params.PRINCIPAL_AMOUNT + compile_params.PRINCIPAL_INTEREST_AMOUNT" },
    { "id": "first_params_burned",  "destination": { "type": "op_return" }, "asset": "compile_params.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "first_params_in.amount_sat" },
    { "id": "second_params_burned", "destination": { "type": "op_return" }, "asset": "compile_params.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "second_params_in.amount_sat" },
    { "id": "borrower_nft_burned",  "destination": { "type": "op_return" }, "asset": "compile_params.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "repayment_change", "destination": "change", "asset": "compile_params.PRINCIPAL_ASSET_ID", "optional": true },
    { "id": "fee_change",       "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

The repayment input requires PRINCIPAL_AMOUNT + PRINCIPAL_INTEREST_AMOUNT — the derived interest the constructor computed and stored. The Lender NFT isn't touched here; it's still in the lender's wallet, waiting to unlock the vault. RepayLoan is the lifecycle's cooperative happy path: state moves to repaid.

LiquidateAfterExpiry — lender seizes (PATH::RIGHT)

If the borrower never repays, the lender takes the collateral — but only after the deadline. The covenant enforces that with an absolute timelock:

#![allow(unused)]
fn main() {
fn loan_liquidation_path() {
    assert!(jet::eq_32(jet::current_index(), 0));
    ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT);
    let first  = ensure_input_and_output_assets_eq(1, 1, param::FIRST_PARAMETERS_NFT_ASSET_ID);   // identity mapping
    let second = ensure_input_and_output_assets_eq(2, 2, param::SECOND_PARAMETERS_NFT_ASSET_ID);
    ensure_input_and_output_assets_with_amount_eq(3, 3, param::LENDER_NFT_ASSET_ID, 1);
    // …unpack & validate terms…
    jet::check_lock_height(loan_expiration_time);  // ← block height must be ≥ expiry
    ensure_output_is_op_return(1);
    ensure_output_is_op_return(2);
    ensure_output_is_op_return(3);
}
}

check_lock_height is the on-chain half of an nLockTime/CLTV timelock: the spend is only valid once the chain reaches LOAN_EXPIRATION_TIME. Note the mapping is the identity here (no payout injected) and the gating token is the Lender NFT, which the lender brings from their wallet — there's no signature, holding the NFT is the authorisation. The collateral lands in the lender's wallet; the NFTs burn. The manifest also declares a friendly pre-build validations check so you get a clean error instead of a rejected broadcast if you try too early:

"validations": [
  { "id": "expiry_reached",
    "rule": { "type": "arithmetic", "expr": "current_block_height >= compile_params.LOAN_EXPIRATION_TIME" },
    "error": { "code": "TIMELOCK_NOT_ELAPSED", "message": "Loan has not yet expired. Cannot liquidate before LOAN_EXPIRATION_TIME." } }
]

This is the unilateral escape hatch for the lender, the mirror image of the borrower's CancelOffer: state moves to liquidated. Compare the relative timelock (check_lock_distance) in the Last Will — that one counts blocks since the UTXO was created; this one names an absolute height.

The principal vault: asset_auth.simf

Whichever way the loan settled honestly, RepayLoan parked the lender's money in a lender_principal_vault UTXO rather than paying the lender directly. Why the extra hop? Because at repayment time the transaction is driven by the borrower — they shouldn't dictate the lender's receiving address, and the lender may be offline. The vault holds the funds under a covenant only the Lender NFT holder can open:

#![allow(unused)]
fn main() {
fn auth_with_burn_check(input_asset_index: u32, output_asset_index: u32) {
    ensure_asset_and_amount_eq(input_asset_index,  true,  param::ASSET_ID, param::ASSET_AMOUNT);  // LENDER_NFT, 1
    ensure_asset_and_amount_eq(output_asset_index, false, param::ASSET_ID, param::ASSET_AMOUNT);
    match param::WITH_ASSET_BURN {
        true  => ensure_output_is_op_return(output_asset_index),  // and the NFT must be burned
        false => {},
    }
}
}

Compiled with ASSET_ID = LENDER_NFT_ASSET_ID, ASSET_AMOUNT = 1, and WITH_ASSET_BURN = true, it says: to move what's in this vault, you must spend the Lender NFT as an input and burn it as an output. The NFT is a one-shot key.

ClaimPrincipalWithInterest — lender withdraws

The lender opens the vault by co-spending and burning their NFT, sending the principal + interest wherever they like:

"ClaimPrincipalWithInterest": {
  "params": { "lender_destination": { "type": "address" } },
  "inputs": [
    { "id": "vault_in", "utxo_source": { "utxo_type": "lender_principal_vault" },
      "witnesses": {
        "INPUT_ASSET_INDEX":  { "type": "formula", "expr": "index_of(lender_nft_in)" },
        "OUTPUT_ASSET_INDEX": { "type": "formula", "expr": "index_of(lender_nft_burned)" },
        "SPEND_PATH": { "type": "taproot_leaf", "source": { "type": "formula", "expr": "lender_principal_vault_leaf" } } } },
    { "id": "lender_nft_in", "utxo_source": "wallet", "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
  ],
  "outputs": [
    { "id": "principal_interest_out", "destination": "params.lender_destination", "asset": "compile_params.PRINCIPAL_ASSET_ID", "amount_sat": "vault_in.amount_sat" },
    { "id": "lender_nft_burned", "destination": { "type": "op_return" }, "asset": "compile_params.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

The index_of(...) formula is the key trick: the covenant needs to know which input is the NFT and which output burns it, but those positions depend on how the builder ordered the transaction. Rather than hard-code indices, the witnesses are computed with index_of(id), which resolves an input/output id to its final position — so the covenant's INPUT_ASSET_INDEX / OUTPUT_ASSET_INDEX always point at the right slots. State moves to settled, and the loan is fully wound down.

Why a vault instead of paying the lender directly — and "accumulation." The asset_auth pattern 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_height is an absolute-height lock, so on testnet you either set a near-future LOAN_EXPIRATION_TIME at construction or wait for the height to arrive. Treat the liquidation path as illustrative until the chain catches up to the deadline you chose.

You've reached the end

That's the whole protocol: a borrower and a lender transacting a collateralised loan with no escrow, every rule — terms, amounts, timelock, payout routing — enforced by five small Simplicity covenants and four NFTs. Along the way you've now seen, working together, every concept the cookbook introduced one recipe at a time: covenant UTXO types, multiple spending paths, issuance & NFTs, formulas & derived params, hooks & tapleaf compute, and the class / instance model.

For the precise rules behind anything here, the authoritative reference is Spec.md.

Field type reference

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

Type 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 (compute: "tapleaf") that compiles a .simf file with params to produce a covenant script hash.

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

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