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 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: the generated field reference, type tables, the formula language grammar, the CLI command list, and a guide for implementing your own wallet.

How to read a recipe

Every recipe in the Cookbook follows the same shape:

Problem — one sentence describing the goal.

Recipe — the manifest JSON you can copy and adapt.

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

Every JSON snippet and command in this book is drawn from real files in the reference wallet (examples/p2pk/, examples/last_will/, examples/lending/) and its CLI — nothing here is invented.

This book describes manifest format 0.2.0. That version string goes in every manifest as manifest_version, and it is checked before anything else runs — a file written against a different revision is refused outright rather than half-understood.

For the exhaustive field list — every type, default and constraint — see the manifest field reference, which is generated from the schema the reference wallet parses with.

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

What is a manifest?

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

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

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

Manifest, instance, state

A live contract is three things, not one:

What it holdsLifetime
The manifestThe protocol definition: contract templates, actions, inputs, outputs, witnesses.Static — one file, shared by every deployment of the protocol.
The instanceThe compile-time field values for one deployment: this borrower's pubkey, this loan's amount.Fixed when the contract is instantiated, then read by every later action.
The stateThe live on-chain UTXO set for that instance.Changes with every broadcast.

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

Only the manifest is a document you write and share. The other two are data a wallet accumulates by running actions, and it must keep them: without the instance it cannot rebuild the contract's addresses, and without the state it cannot find the UTXOs to spend.

Where that data lives is the wallet's business. The format says what has to persist between actions, never how to store it. The CLI in this book writes JSON files next to the manifest; a browser extension would use its own storage, and a hosted wallet a database. Nothing in a manifest depends on the choice.

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

What a manifest contains

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

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

Anatomy of a manifest

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

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

  "utxo_types":         { ... },
  "contract_templates": { ... },
  "actions":            { ... }
}

The envelope

FieldRequiredPurpose
manifest_versionyesVersion of the tx-manifest format itself. Current: "0.2.0".
protocolyesKebab-case protocol identifier, e.g. "simplicity-lending".
descriptionnoFree-text summary of the whole protocol.
chainno"bitcoin", "liquid"/"elements", or "cross-chain". Defaults to "elements".
simplicity_hlnoSimplicityHL toolchain settings. See below.

Two authoring keys may appear on any object at any depth and carry no protocol meaning: $comment, for prose a tool must ignore, and $schema, an editor hint pointing at the JSON Schema. Both are stripped before a manifest is interpreted.

simplicity_hl — how the covenants are compiled

The whole block is optional, and so is every field in it. Written out with its defaults, it says exactly what omitting it says:

"simplicity_hl": {
  "debug_symbols": false,
  "unstable_features": []
}
FieldTypeDefaultEffect
debug_symbolsbooleanfalseCompile the .simf programs with debug symbols. Changes every covenant address.
unstable_featuresarray of strings[]Allow gated SimplicityHL syntax. Never changes an address.

debug_symbols

Compiles contracts with debug_symbols enabled. Note that this will change the CMR and addresses generated. In general this should not be used (or set to false), but is present to interact with contracts that are already deployed with this set to true.

unstable_features

The manifest form of simc -Z <name>. If you are using a contract that requires unstable features, you can enable them here.

The data sections

Three sections describe the protocol itself. Most files carry two of them, and none is required — a manifest with an empty actions map is still a manifest. Each gets a chapter of its own; this table is the map.

SectionHoldsCovered in
utxo_typesThe covenant addresses this protocol locks value to — one named entry per on-chain state, each pointing at a .simf programCovenant UTXO types
actionsOne transaction recipe each: which UTXOs to consume, what to create, what witnesses satisfy themHello World, Outputs & destinations, Witnesses
contract_templatesA typed contract: named fields plus the actions that operate on them, with one instance per deploymentInstance, state & constructors

Two shapes, and when the second one arrives

A protocol with a single contract type can put its actions straight under top-level actions, and give each one the params it needs. That is the whole file — no templates, no instance. Every recipe up to Witnesses is written this way.

contract_templates earns its place the moment a value has to outlive a single transaction. A loan's collateral amount is chosen once, baked into a covenant address, and then read back by four later actions; it cannot be a param, because a param is gone when the transaction is built. Declaring it as a template field stores it in the instance, where later actions reach it as instance.NAME. That is the whole distinction, and Parameters draws it out properly.

The two are not alternatives to choose between up front. A file may carry top-level actions and contract_templates at once — the lending example does, using standalone actions for setup work that belongs to no instance.

Read a real one. The fastest way to see how these fit together is examples/p2pk/txmanifest.json in the wallet repository — the smallest complete manifest — and then examples/lending_v3, which uses every section at once.

For the exhaustive list of fields each section accepts, with types and defaults, see the manifest field reference.

What runs it

A manifest is declarative: it says what a transaction must look like, never how to build one. Turning it into a signed transaction is a wallet's job — tx-manifest-wallet resolves the params, finds the inputs, compiles the covenants, builds and signs a PSET, dry-runs the Simplicity programs, and broadcasts. Each recipe introduces the parts it needs, so there is nothing to learn here up front; the wallet implementation guide has the full sequence for when you want it.

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

Installing the CLI

To run the recipes in this book you need a wallet that understands txmanifest. An example wallet is provided at https://github.com/stringhandler/txmanifest-wallet

This page installs that CLI and points it at a network. Creating a wallet then gets you keys and coins.

tx-manifest-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, and this book calls it txw throughout — the two are interchangeable. There are two ways to get it.

The codespace installs nothing on your machine and is the fastest way to run a recipe. A local install is what you want if you would rather work in your own editor, or you are on arm64.

The codespace (nothing to install)

txw-codespace is a dev container with the wallet already installed and nothing else. Click the badge in its README, or from the repo page choose Code ▸ Codespaces ▸ Create codespace on main. First creation takes a couple of minutes and ends with Ready. in the log.

Then:

txw --help

Three things about it are worth knowing before you start:

  • work/ is entirely gitignored. Put your manifests, wallets and state files there and nothing of yours can be committed by accident. Start every recipe with cd work.

  • txw is a wrapper script, not a shell alias, so it works from scripts and editor tasks as well as from the prompt.

  • The recipes that use bundled examples need one command first. The examples are not copied into the codespace, where they would drift from the format the installed wallet speaks. Fetch them on demand:

    ./scripts/fetch-examples.sh
    

    That puts a sparse clone in work/txmanifest-wallet/, keeping upstream's examples/ and schema/ layout so the manifests' relative $schema references still resolve.

It runs on x86_64 only, because that is what the wallet publishes Linux binaries for. On an arm64 machine, use a local build.

A local install

A prebuilt binary is the simplest. Grab one from the releases page — Linux x86_64, macOS Apple Silicon, and Windows x86_64 are published — unpack it, and put tx-manifest-wallet on your PATH:

curl -LO https://github.com/stringhandler/txmanifest-wallet/releases/download/v0.2.2/tx-manifest-wallet-v0.2.2-x86_64-unknown-linux-gnu.tar.gz
tar xzf tx-manifest-wallet-v0.2.2-x86_64-unknown-linux-gnu.tar.gz
sudo mv tx-manifest-wallet /usr/local/bin/
alias txw=tx-manifest-wallet
txw --help

Asset names are version-stamped, so check the releases page for the current one. The macOS and Windows builds are tx-manifest-wallet-<version>-aarch64-apple-darwin.tar.gz and tx-manifest-wallet-<version>-x86_64-pc-windows-msvc.zip.

Or via asdf, which is how the codespace does it, and lets you keep several versions side by side (Linux x86_64 and macOS Apple Silicon; asdf is shell-based, so not Windows):

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

Or from source, which is the arm64 answer and the one to use if you want to change the wallet itself. It is the txmanifest_wallet crate of a standard Cargo workspace:

git clone https://github.com/stringhandler/txmanifest-wallet
cd txmanifest-wallet
cargo build --release          # binary at ./target/release/tx-manifest-wallet
alias txw="$(pwd)/target/release/tx-manifest-wallet"
txw --help

A source clone also gives you examples/ directly, which is what the recipes' bundled-example paths refer to.

Check the version speaks this format

This book is written against manifest format 0.2.0, and a wallet checks that before it does anything else. Any 0.2.x release speaks it; a 0.1.x one will refuse every manifest here.

txw --version

In the codespace the version is pinned in .tool-versions and can be changed without a rebuild:

./scripts/set-wallet-version.sh latest

Throughout the book, commands are written as txw <subcommand>. Manifest paths like examples/p2pk/txmanifest.json are relative to your current directory — run them from a source clone, or from work/txmanifest-wallet/ in the codespace.

You do not need simc installed. The SimplicityHL compiler is linked into the wallet as a library, so txw compiles the .simf files a manifest points at in-process. Nothing shells out, and there is no separate toolchain to keep in step. The simc "=x.y.z"; directive inside a .simf still applies — it is enforced by that same linked compiler.

Configure the network and backend

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

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

Run config with no arguments to print the current values:

txw config

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

With the CLI installed and pointed at testnet, the next step is a wallet: Creating a wallet.

Creating a wallet

tx-manifest-wallet needs keys to select UTXOs and to sign. This page creates a wallet, funds it from the testnet faucet, and prepares the UTXOs an action needs.

This is a txw page, not a format page. How a wallet holds keys, where it keeps them, and what it calls the command to make one are all its own business. Nothing here appears in a manifest.

Make a wallet

txw create-wallet --out wallet.json

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

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

txw info --wallet wallet.json

⚠️ Use this wallet for testing only. create-wallet generates a fresh 12-word BIP39 seed phrase and writes it to wallet.json in plaintext. Anyone who can read that file — a backup, a synced folder, a shared machine, a screenshot — controls every coin it holds, and there is no passphrase or encryption to stop them.

This is a deliberate simplification in an example CLI, not an oversight to be worked around: it keeps the recipes runnable without a key-management detour. A real wallet encrypts key material at rest and keeps it off disk while unlocked.

So treat wallet.json as disposable and keep it on testnet. Don't reuse a seed you care about, don't commit the file, and don't put mainnet funds behind it — --mainnet true exists for completeness, not as a recommendation.

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: Splitting a UTXO, a warm-up with no covenant in it.

Splitting a UTXO

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

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

You build it a piece at a time here, and run it in the next recipe.

Start with a skeleton

Create txmanifest.json with the envelope and one empty section:

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

  "actions": {}
}

Only manifest_version and protocol are required; description and chain are here because they are useful. There is no utxo_types section, because nothing is locked to a covenant, and no contract_templates, because nothing is baked into a script. A manifest really can be this small.

Add the action shell

An action is one transaction recipe. Give it a name, a description, and the values it will ask you for. Replace the empty actions with:

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

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

Split is the name you will type on the command line. Its one param, amount_each, is a value you supply each time you run the action — the tool prompts for it, and the description is the hint it shows you.

A param affects only the transaction being built. It never changes a script or an address, which is the distinction Parameters makes precise later.

An action with no inputs and no outputs is legal but builds nothing, so it needs both.

Add the input

inputs lists the UTXOs the transaction consumes. This one needs a single wallet UTXO, big enough to cover four shares. Add it inside "Split", after params:

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

Three things are being said here:

  • utxo_source: "wallet" — take any wallet-controlled UTXO. The alternative, which the next recipe uses, is to name a covenant to spend from.
  • id — a handle for this input. Other parts of the action refer to it by this name, and errors quote it.
  • amount_sat as { "min_amount": ... } — not a fixed amount, but a floor. The tool auto-selects a UTXO worth at least params.amount_each * 4. That expression is a formula; params.X reads the param you just declared.

Add the outputs

outputs lists what the transaction creates, in the order they will appear. Add it after inputs:

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

Each split_n sends amount_each to destination: "wallet" — your own receive address. They are written out four times rather than looped; a manifest has no loops, and every output a transaction carries is written in the file.

That looks complete — and it will not run. Why not is the next recipe.

What you have so far

An envelope and one action. No utxo_types, because nothing is locked to a covenant; no witnesses, because every input is an ordinary wallet UTXO the wallet signs the usual way. Witnesses appear the moment you spend a covenant, which is two recipes away.

The file so far
{
  "manifest_version": "0.2.0",
  "protocol": "utxo-split",
  "description": "Split one wallet UTXO into four equal wallet UTXOs.",
  "chain": "liquid",

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

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

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

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

Try next

The manifest is written. In the next recipe you run it — and it fails, for a reason worth understanding before you write anything larger: Running it: change and fees.

Running it: change and fees

Problem. Run the manifest you just wrote, and account for every satoshi it moves — including the ones you did not think about.

You have a manifest (Splitting a UTXO) but you have not run anything yet. Doing so is how you meet the rule that governs every transaction a manifest builds: every output a transaction carries has to come from the manifest. The fee is the single exception.

Run it — and watch it fail

First, a funded wallet. If you have not done this yet, create one and fill it from the faucet (Creating a wallet):

txw info --wallet wallet.json     # copy the receive address

Paste that address into the Liquid testnet faucet, request the coins, then pick them up:

txw sync --wallet wallet.json

Now run the action. It is worth checking the file parses first — validate touches no network and no wallet:

txw validate txmanifest.json

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

You will be prompted for amount_each. Give it something well under a quarter of your balance, and the build stops:

[error] PSET build failed:
  0: L-BTC does not balance: inputs exceed outputs by 600 sat, but the fee is 486 sat, leaving 114 sat unaccounted for.
This action does not permit L-BTC change, so there is nowhere for it to go. Either set "allow_change": "lbtc_only" on the action, declare a change output, or size the input to outputs + fee exactly.

Nothing was broadcast and nothing was spent — this failed while building the transaction, before any signature existed.

Where does the surplus go?

The input you selected is worth more than the four outputs plus the fee. That leftover has to go somewhere, and the engine will not decide for you.

It could send it to your change address — but you did not ask for that. It could add it to the fee — but then 114 satoshis leave your wallet to a miner, in an amount nobody wrote down. Every output a transaction carries has to come from the manifest, so rather than invent one, it stops and tells you the three ways out.

Fix it: declare a change output

The usual answer. Add a fifth output:

        { "id": "change_out", "destination": "change", "asset": "lbtc", "optional": true }

It differs from the four above in three ways, and all three matter:

  • destination: "change" sends to your change address rather than your receive address.
  • No amount_sat. A change output takes whatever is left after the other outputs and the fee. It is the only kind of output that computes its own amount.
  • optional: true lets the action drop it, because that remainder can be zero — and a zero-satoshi output is not something you can broadcast.

With it declared, your input only has to be big enough. Too small and you get the other error, which is the same arithmetic from the other side:

Insufficient L-BTC: have 41000 sat, need 42126 sat (outputs 40000 + fee 2126)

Or: let the engine add one

The other answer from the error message. Add allow_change to the action, beside description:

      "allow_change": "lbtc_only"

This permits the engine to return an L-BTC surplus without a declared output. "lbtc_only" is the setting worth reaching for: a surplus in any other asset is still an error, so a protocol token you failed to account for cannot quietly walk out. The default is "none", which is why you saw the error at all.

Which one

Declare a change output when you want the change to be part of the transaction you described — as Split does. Set allow_change when the surplus is an artefact of fee estimation rather than something the action is about.

And sometimes neither is right. An action with no change output must size its outputs to the input exactly, fee included, which sounds impractical until you meet a recursive covenant: the Last Will refresh path recreates its own covenant and may have only two outputs, so it computes its amount as will_in.amount_sat - fee and declares no change at all.

Take the first fix — add change_out — and carry on.

Where is the fee?

Elements transactions pay the fee as a real output — an explicit L-BTC output with an empty script. So this transaction will have six outputs, not five.

The fee is the one output the engine appends on its own, because its amount is only known once the transaction's size is, and that is after everything else is decided. It is always last.

That exception is worth remembering when you read a covenant. A program that asserts num_outputs == 2 against a manifest declaring a single output is not wrong — it is counting the fee.

If you want the fee visible in the manifest anyway, an output can declare "destination": { "type": "fee" }. It produces nothing on its own; it is a note to the reader that the fee leg is expected here. None of the examples use it, but a covenant that counts outputs is the case where it earns its place.

Run it again

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

This time it selects an input, builds four outputs plus change, signs, and broadcasts. Afterwards your wallet holds four fresh UTXOs:

txw sync --wallet wallet.json
txw get-balance --wallet wallet.json

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

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

And prepare will split automatically when an action needs more UTXOs than the wallet currently has. You have just written the long way round, which is the point — the next recipe does something no built-in command can.

The finished file

The complete file
{
  "manifest_version": "0.2.0",
  "protocol": "utxo-split",
  "description": "Split one wallet UTXO into four equal wallet UTXOs.",
  "chain": "liquid",

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

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

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

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

Try next

You have written a manifest and broadcast a transaction from it, and you know where every satoshi went. The next recipe adds the first real Simplicity covenant — a UTXO type, a script, and the witnesses to spend it: Hello World: Pay-to-Public-Key.

Hello World: Pay-to-Public-Key

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

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

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

Introducing Simplicity

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

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

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

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

The program: p2pk.simf

Create p2pk.simf with exactly this content:

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

Four pieces:

  • witness::SIGNATURE — a value supplied by the spender at spend time. The program reads it 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.2.0",
  "protocol": "p2pk-simplicity",
  "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.",
  "chain": "liquid",

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

That is the whole shape: the envelope (the four fields at the top, covered in Anatomy of a manifest) followed by two empty data sections we'll fill in below — the UTXO type and the Pay action. There's no contract_templates block, because the recipient's key is a runtime parameter of the action rather than a value fixed at deploy time.

We'll fill the two sections in order.

Fill in the UTXO type

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

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

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

Fill in the Pay action

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

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

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

With both sections filled in you have the Pay half of txmanifest.json under examples/p2pk/. That file also carries the Receive action, which Part 2 builds.

How it works

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

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

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

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

Run it

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

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

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

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

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

Tip. Add --export-pset out.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 contract's state

After a successful Pay, the new covenant output is recorded in the contract's state — the set of UTXOs this deployment currently owns. This is what the CLI stored:

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

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

Any action that spends a covenant needs this state; without it a wallet has no idea the UTXO exists. Carrying it from one action to the next is the wallet's job, and every wallet does it differently — this CLI keeps it in a file you name with --state, which the next lesson uses. See the CLI reference for how it handles those files.

No instance

A contract accumulates up to two kinds of data: an instance (compile-time field values) and its state (live UTXOs). This lesson produced only state — there is no instance at all.

Why? An instance exists to persist a contract template's fields, and this contract declares no contract_templates at all. The recipient's key is an action param, supplied fresh at run time and never stored. With no template fields to record, there is nothing for an instance to hold. Instances first appear once we introduce contract templates and constructors in Instance, state & constructors.

Try next

You now have a covenant on-chain. The next recipe digs deeper into parameters — the pubkey and amount_sat values you just supplied — and shows how a param can fill itself in: Parameters.

Hello World, Part 2: Spending the output

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

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

  1. The contract's state locates the UTXO. We never type a txid — the wallet already knows the live p2pk_output entry, because Pay recorded it.
  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 the contract's state 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 in the contract state by its utxo_type.",
          "utxo_source": {
            "utxo_type": "p2pk_output",
            "compile_params": { "PUB_KEY": "params.pubkey" }
          },
          "witnesses": {
            "SIGNATURE": {
              "type": "Signature",
              "sig_type": "sig_hash_all",
              "source": { "type": "wallet", "key": "params.pubkey" },
              "description": "BIP340 Schnorr signature over the whole transaction, from the recipient key."
            }
          }
        },
        {
          "id": "fee_input",
          "description": "Wallet L-BTC UTXO to pay the network fee.",
          "utxo_source": "wallet",
          "asset": "lbtc",
          "optional": true
        }
      ],
      "outputs": [
        {
          "id": "received_out",
          "description": "The reclaimed funds, sent to your wallet.",
          "destination": "wallet",
          "asset": "lbtc",
          "amount_sat": "p2pk_in.amount_sat"
        },
        {
          "id": "fee_change",
          "description": "L-BTC change from the fee input.",
          "destination": "change",
          "asset": "lbtc",
          "optional": true
        }
      ]
    }

How it works

The input comes from the contract's state, not your wallet. p2pk_in's utxo_source is { "utxo_type": "p2pk_output" }. Unlike a "wallet" input, this tells the tool to look in the contract's state for a live UTXO of that type — the very one Pay recorded. With no state to look in, the input cannot resolve.

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

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

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

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

  • type: "Signature" — the tool computes the signature itself rather than taking a literal value.
  • sig_type: "sig_hash_all" — the message to sign is Simplicity'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 contract's state from Part 1, run:

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

This CLI takes the state as a file, and does not go looking for one — substitute the path your Pay actually wrote. Another wallet would find its own stored state without being told.

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

Tip. Add --debug-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 after spending

A successful Receive consumes the covenant UTXO, so it is removed from the contract's state. If that was the only entry, utxos is now empty:

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

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

Try next

You've now built and spent a covenant — the full lifecycle of the simplest contract. The next recipe looks more closely at the parameters that drive these actions, and at which of them change the covenant's address: Parameters.

Parameters

Problem. Understand when a value belongs to a contract template's fields versus an action's params, and how a value gets filled in without prompting.

The Pay action from recipe 1 takes one value from you each time it runs, and bakes another into the covenant address forever. This recipe pins down the difference.

Two kinds of parameters

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

template fieldsaction 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 instancenowhere; supplied at run time
Referenced asinstance.NAMEparams.NAME
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 belongs in a template's fields. If it only affects which inputs and outputs this particular transaction picks, it's an action param.

A single-type contract that never needs an instance can skip contract_templates altogether and declare everything as action params — which is what the early recipes in this book do.

Params that fill themselves in: compute

A param that declares a compute block is never prompted for. The simplest form is a bare formula string; the structured forms cover what an expression cannot say:

"params": {
  "BORROWER_PUB_KEY": {
    "type": "pubkey",
    "description": "Borrower's signing key.",
    "compute": { "type": "wallet", "wallet": "key" }
  },
  "PRINCIPAL_INTEREST_AMOUNT": {
    "type": "u64",
    "compute": "instance.PRINCIPAL_AMOUNT * instance.INTEREST_RATE / 10000"
  }
}
compute.typeProduces
(bare string) or exprThe value of a formula
walletA value from the executing wallet — see below
script_hashsha256(scriptPubKey) of an address
tapleafA covenant script hash, by compiling a .simf
simf_fnThe return value of a named function in a .simf
hookNothing here — a hook sets this param later in the run

The wallet variant takes a second key naming which wallet-derived value it wants:

compute.walletResolves to
"key"The wallet's 32-byte x-only BIP340 pubkey
"script_hash"sha256(scriptPubKey) of the wallet's index-0 explicit output
"address"The explicit address matching "script_hash" — the two are a pair

Declaring { "type": "hook" } looks pointless but is not: it is how a param that a hook will set gets an identifier. Without the declaration, a hook writing to params.SOMETHING would be inventing a name nothing checks.

If a param has no compute, the tool prompts you for it interactively (or you supply it via --params, below). In recipe 1, PUBKEY has no compute, so Pay prompts you for it.

Where a manifest's guarantees come from

There is no block for business rules — no place to write "amount must be greater than zero" and have the wallet check it. That is deliberate: a rule a wallet enforces is a rule a different wallet can skip. Guarantees come from three places instead, in descending order of strength:

  • The covenant. Anything that actually protects value belongs in the .simf, where the chain enforces it and no wallet can skip it. If a value matters, make the program check it.
  • allow_change. An action defaults to "none", so a surplus that no declared output absorbs is an error rather than a silent change output moving value somewhere you never wrote down.
  • Parsing. Unknown and misspelled fields are hard errors, and manifest_version is checked before anything else runs, so a file written for a different format revision fails immediately rather than half-working.

Run it

Supplying params non-interactively

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

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

Keys must match the action's param names exactly — pubkey, not PUBKEY.

The CLI also auto-discovers a per-network param file sitting next to the manifest, named <manifest-stem>.<network>.json — so txmanifest.testnet.json beside txmanifest.json is picked up whenever you pass --network testnet. An explicit --params file is loaded after it and wins on any key they share.

Try next

That covers the values going into an action. Next we get precise about what comes out: the destinations a value can go to, and how confidentiality is decided: Outputs & destinations.

Outputs & destinations

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

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

The output descriptor

An output descriptor has these fields:

FieldRequiredPurpose
idyesUnique name within the action.
destinationyesWhere the value goes. See below.
amount_satnoAmount in satoshis — a literal or a formula. Omit it for a change destination, which auto-computes the remainder.
assetnoAsset ID — "lbtc", a 64-char hex ID, or a reference. Omit only where the destination implies it.
descriptionnoHuman-readable purpose.
optionalnoIf true, the output may be omitted (e.g. zero change). Default false.
confidentialnoWhether to blind this output. See the rules below.
blindingnoPin this output's asset_bf / value_bf instead of letting the builder choose.
datanoOP_RETURN payload — only valid with the op_return destination.
uinoClear-signing label and role for this leg.
required_indexnoDocuments the index the covenant expects. Not enforced — see below.

Recipe: every destination type

Wallet and change

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

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

An address supplied at run time

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

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

A covenant UTXO type

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

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

A raw script hash from a compile param

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

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

OP_RETURN

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

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

  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.

    data takes either the concat(...) string above, or an object of typed parts when the exact byte layout matters:

    "data": { "parts": [
      { "type": "bytes32", "value": "instance.BORROWER_PUB_KEY" },
      { "type": "u64", "value": "instance.PRINCIPAL_AMOUNT" }
    ] }
    

    Use the object form when a field's width or endianness has to be pinned; the string form is shorthand for concatenating 32-byte references.

  2. Burning a token. Spend an NFT into OP_RETURN to destroy it — the lending protocol burns auth NFTs this way to prevent reuse.

burn

{ "id": "nft_burned", "destination": { "type": "burn" }, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 }

The engine treats burn exactly as op_return: a bare OP_RETURN unless the output carries a data field, in which case the bytes are embedded. The two are interchangeable, so pick the one that says what you mean — burn when the point is destroying a token, op_return when the point is publishing bytes.

fee

{ "id": "network_fee", "destination": { "type": "fee" } }

A declaration, not an output. Elements pays the fee as a real explicit L-BTC output, but the engine always appends that itself — its amount is only known once the transaction's size is. This destination produces nothing of its own.

Declare it when a covenant counts outputs and you want the manifest to show the leg the program is counting. Otherwise leave it out; every example does.

Surplus, change and fees

An output with "destination": "change" is a declared change output, and takes whatever is left after the other outputs and the fee. An action can also permit the engine to add a change output it never declared, with allow_change: "none" (the default), "lbtc_only", or "any".

Both, and the arithmetic behind them, are covered where you first meet them: Running it: change and fees.

How confidentiality is decided

On Liquid, outputs can be blinded (amount and asset hidden). Blinding is decided per output:

  1. The output's own confidential field, if present.
  2. Otherwise the chain default: false for Bitcoin, true for Liquid/Elements.

There is no file-level or per-utxo_type default to fall back on, and that is the right granularity: blinding is a property of an output, not of an address. One covenant address can hold a blinded reissuance token beside an explicit collateral UTXO, so only the output itself can answer the question.

An output may also carry a blinding block pinning the asset_bf and value_bf scalars the builder would otherwise choose at random. That matters when a covenant verifies its own UTXOs as Pedersen commitments and so has to know the factors in advance.

OP_RETURN outputs are always explicit — there is nothing to hide in a provably unspendable output.

Blinding a covenant output is allowed, and usually wrong. Setting confidential: true on a utxo_type destination does work: the output is blinded with the wallet's change blinding key, and a later action can spend it by declaring the factors it was created with. But a Simplicity covenant reads explicit amounts and asset IDs through jets like current_amount and current_asset, and cannot read a Pedersen commitment. So blind a covenant output only when the program never introspects the value — and when it does need to, pin the factors with blinding so the spend can reconstruct the prevout.

Output order, and required_index

Covenants that introspect the transaction often require outputs in an exact order ("collateral at output 0, principal at output 1"). What puts an output at a given index is the order you declare it in — outputs are built in the order they appear in the outputs array. If the covenant wants the collateral first, write the collateral output first.

required_index records that expectation:

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

required_index is documentation. Nothing checks it. The tool parses the field and never compares it against where the output actually landed, so a value that disagrees with the declaration order is silently ignored and the covenant sees the declared order. Treat it as a comment that tells the next reader — and you, six months later — which positions the program depends on. The thing that has to be right is the order of the array.

The same field exists on inputs, with the same caveat. Input order likewise comes from the inputs array.

Run it

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

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

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

Try next

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

Witnesses

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

You met your first witness in Hello World, Part 2: the SIGNATURE that satisfied p2pk.simf. This recipe steps back and covers the witnesses map in full — what it is, the forms a witness takes, and the rule that every witness the program declares must be named.

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

Where witnesses live

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

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

Each key is a SimplicityHL witness name — it must match a witness::NAME in the .simf source. Our program reads witness::SIGNATURE, so the map has a SIGNATURE entry.

The map and the program must agree exactly, in both directions. Every witness the program declares has to appear in the map, and every entry in the map has to name a witness the program declares. A name the program doesn't have is an error ("'X' is declared here but the program has no such witness"); so is a witness you left out. Nothing is inferred from an omission — see Witnesses on the path you didn't take.

taproot_leaf is the single exception, because it is not a program witness at all. More on that below.

The forms a witness takes

Signature — a computed BIP340 signature

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

"SIGNATURE": {
  "type": "Signature",
  "sig_type": "sig_hash_all",
  "source": { "type": "wallet", "key": "params.pubkey" }
}
  • sig_type: "sig_hash_all" selects the message to sign: Simplicity'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 contract-template field (instance.BORROWER_PUB_KEY, the form the lending example uses), or a literal hex value. The tool searches your wallet's BIP86 derivation paths for the private key matching that pubkey and signs with it.

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

simplicityhl — a literal typed value

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

"PATH": {
  "type": "simplicityhl",
  "value": "Left(())",
  "simplicity_type": "Either<(), ()>",
  "description": "Take the first spending path."
}
  • 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.

taproot_leaf — which leaf to spend

Not a program witness at all. A covenant whose tap tree has more than one leaf needs to say which one this spend uses, and that selection is made outside the program:

"SPEND_PATH": {
  "type": "taproot_leaf",
  "source": { "type": "formula", "expr": "pre_lock_leaf" }
}

Because the program never reads it, it is exempt from the agreement rule above: it does not have to correspond to a witness:: name, and it is skipped when the map is matched against the program's witness list. Do not confuse it with a PATH selector — PATH chooses a branch inside one program; SPEND_PATH chooses which program runs.

"unused" — a witness this path doesn't read

The bare string, in place of an object:

"HOT_SIG": "unused"

Covered in full in the next section.

Witnesses on the path you didn't take

A program declares every witness it could read, but a single spend travels one path. The other branches still need values — before Simplicity prunes them, every witness node needs some concrete bit-vector — so those slots have to be filled with zeros.

You have to ask for that zero explicitly, by name. Write the string "unused" where the object would go:

"witnesses": {
  "SPEND_PATH":     { "type": "simplicityhl", "value": "Right(Left(()))" },
  "COLD_SIG":       { "type": "Signature", "sig_type": "sig_hash_all",
                      "source": { "type": "wallet", "key": "instance.COLD_PUB_KEY" } },
  "HOT_SIG":        "unused",
  "INHERITOR_SIG":  "unused"
}

That is the last_will cold-key spend: four witnesses declared, one selector, one real signature, two zeros — and all four named.

Why name them at all, rather than let the tool fill in the blanks? Because then a misspelled HOT_SIG and a deliberate omission would be the same thing. Both would leave the slot at zero, the transaction would build, and the mistake would surface much later as a covenant that simply does not satisfy — with nothing pointing at the cause. Naming every witness costs one line each and lets the tool check your map against the .simf before it builds anything.

The clearest illustration is one covenant spent two ways. The lending pre_lock program declares PATH and SIGNATURE. SetupLending takes the left path, which reads no signature:

"PATH":      { "type": "simplicityhl", "value": "Left(())" },
"SIGNATURE": "unused"

CancelOffer takes the right path, which does:

"PATH":      { "type": "simplicityhl", "value": "Right(())" },
"SIGNATURE": { "type": "Signature", "sig_type": "sig_hash_all",
               "source": { "type": "wallet", "key": "instance.BORROWER_PUB_KEY" } }

Same two names in both. Only the values differ.

Our single-path p2pk.simf declares just SIGNATURE and always reads it, so nothing is ever "unused" there.

What the tool builds

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

[ witness_bits, pruned_program, cmr_script, control_block ]

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

When it goes wrong

The four legal forms are "type": "simplicityhl", "type": "Signature", "type": "taproot_leaf", and the bare string "unused". Anything else is rejected by name, so the common mistakes report themselves:

MessageCause
'X' is declared here but the program has no such witnessA name in the map the .simf doesn't declare — usually a typo, or a witness renamed in the program
missing / nothing supplied for a declared witnessA witness left out entirely. Add it, with "unused" if this path doesn't read it
'X' has unrecognized type 'Y'A type outside the four above
'X' is a Signature witness that was never signedNo signer was available — the source key isn't one your wallet can derive

That last one is worth recognising: it means the entry was still a Signature when the tool went to build the witness stack, i.e. the key lookup failed. It is not a problem with the program.

See also

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 — but all four witnesses must still be named on the input, the two you don't read as "unused".

The two interesting helpers:

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

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

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

The manifest

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

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

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

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

The constructor: Fund

Fund does double duty — it locks the funds and creates the instance. It takes the three keys as params (two auto-filled from your wallet) and an amount, locks a wallet UTXO into the covenant, then create_instance records the keys. Carrying that block is what makes Fund a constructor — there is no separate flag to set:

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

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

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

Each spend reads the instance

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

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

This is exactly the Hello World spend plus a SPEND_PATH selector — and the two signatures this path never reads, named as "unused" because the program declares them. Inherit is the same shape with SPEND_PATH = Left(()), a real INHERITOR_SIG, and COLD_SIG / HOT_SIG unused.

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

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

Three things the covenant dictates here:

  • The re-locked output is declared first. recursive_covenant checks output_script_hash(0), so will_again has to be output 0 — and it is, because it is the first entry in the outputs array. The required_index: 0 beside it records that dependency for a reader; it is not what enforces it.
  • No change output. The program asserts exactly two outputs (covenant + fee). Because this action declares no "change" output, the builder never adds one — it folds the L-BTC surplus into the fee. There's no separate fee input either: a recursive covenant can't add a wallet change output to return the leftover, so the will pays its own fee and shrinks by it each refresh.
  • amount_sat: "will_in.amount_sat - fee" — 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 compute; the file just makes every value explicit.)

Run it

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

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

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

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

Both flags are needed, and this CLI does not go looking for either: --instance supplies the three keys the covenant address is rebuilt from, and --state supplies the UTXO to spend. Substitute the paths Fund actually wrote.

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

Inherit and the relative timelock

inherit_spend calls jet::check_lock_distance, which reads the spending input's nSequence. A transaction that doesn't set it fails the check no matter how old the UTXO is, so the input has to ask for the lock explicitly:

{
  "id": "will_in",
  "utxo_source": { "utxo_type": "last_will" },
  "sequence": { "relative_blocks": "instance.INHERIT_BLOCKS" },
  "witnesses": { "…": "…" }
}

sequence takes one of three forms:

FormMeaning
{ "relative_blocks": <expr> }BIP68 block-based lock, at most 65535 blocks
{ "relative_seconds": <expr> }time-based lock, rounded up to 512-second units
a bare integer or expressiona raw nSequence value

Omit it and the input stays at Sequence::MAX, which disables relative locktime — the default, and the reason every other action here leaves it out.

Setting it does not make the UTXO old enough; it declares the age the spend claims, and the network rejects the transaction until the UTXO actually reaches it. Inherit is also signed by the heir's key rather than yours, so running it needs the heir's wallet.

Caveat on Refresh. It relies on the explicit-fee / no-change output layout with 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.
  • Covenant address determinism: same .simf + same params + same debug_symbols → same address, always. The P2TR(NUMS, tapbranch(...)) construction.
  • params on the type and args at each site: closing a UTXO type's scope so its address derivation reads only what it declares.
  • extra_leaves for appending additional taproot leaves.

In the meantime, UtxoType and UtxoScript in the field reference list every key, and examples/last_will is the smallest covenant with more than one leaf.

Multiple spending paths

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

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

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

  • A Simplicity program with Either<(), ()> paths (PATH::LEFT / PATH::RIGHT).
  • Selecting a path with a 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.
  • Declaring inputs in the order the covenant's introspection expects.
  • Cooperative vs unilateral paths, and how each is enforced by the covenant.

See the pre_lock discussion in Accepting or cancelling the offer (and the lending covenant in Settling the loan) in the meantime.

Formulas & derived params

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

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

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

  • Where formulas appear: output/input amount_sat, a param's compute, and hook set values. (Not witnesses — a witness takes a literal value, not an expression.)
  • Operators (+ - * /, comparisons, && || !) and references (instance.X, params.X, input_id.amount_sat, input_id.asset, input_id.present).
  • Functions: pow(base, exp), and concat(...) for OP_RETURN data only.
  • The special fee value used in change and re-lock formulas.
  • Computed params ("compute": "..."): interest = PRINCIPAL_AMOUNT * PRINCIPAL_INTEREST_RATE / 10000, and params derived from issuance outpoints.

In the meantime the formula language reference has the operators, the references, and the $ prefix rule.

Asset issuance & NFTs

Problem. Mint a brand-new Liquid asset — including a single-unit NFT — as part of an action, and use its derived asset ID in the same transaction and in later ones.

Every recipe so far has moved assets that already existed (L-BTC, a covenant's collateral). This one creates them. On Liquid, an asset is issued as a property of a transaction input: you point at a wallet UTXO, attach an issuance block, and the transaction mints a new asset whose ID is derived from that input. Two ideas do most of the work:

  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_satnew onlyHow many reissuance tokens to mint. 0 fixes the supply forever — nobody can ever issue more.
entropyreissue onlyThe issuance entropy of the original mint. See below.
issued_assetnoOn a reissue, the asset ID you expect. A check, not an input.

Either amount may be 0: an issuance of reissuance tokens alone, or of a fixed-supply asset with no reissuance rights.

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

Why the asset ID comes from the outpoint

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

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

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

Capturing the new asset ID with on_resolved

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

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

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

Hooks recap. on_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.

Minting more later: reissue

Issuing more of an existing asset means spending its reissuance token, and that needs a value you can only capture at the original mint: the issuance entropy, fast_merkle_root([sha256d(defining outpoint), contract_hash]). The asset ID is derived from it.

It cannot be recovered from the chain. The reissuance-token UTXO carries no trace of the outpoint that created it, so if the constructor doesn't record the entropy, the asset can never be reissued. Capture it in create_instance, where $inputs.<input_id>.issuance_entropy reaches it:

"create_instance": {
  "fields": {
    "TOKEN_ISSUANCE_ENTROPY": "$inputs.defining_in.issuance_entropy",
    "TOKEN_ASSET": "$inputs.defining_in.issued_asset"
  }
}

The reissuing action then hands it back:

{
  "id": "reissue_in",
  "utxo_source": "wallet",
  "asset": "instance.TOKEN_REISSUANCE_TOKEN",
  "issuance": {
    "kind": "reissue",
    "asset_amount_sat": "params.EXTRA_UNITS",
    "entropy": "instance.TOKEN_ISSUANCE_ENTROPY",
    "issued_asset": "instance.TOKEN_ASSET"
  }
}

issued_asset is optional and is worth supplying. The engine re-derives the asset ID from the entropy and refuses to build if the two disagree — and since an entropy is opaque, and block explorers print it in the reverse byte order from the one used here, a transposed value would otherwise build a perfectly broadcastable transaction that reissues the wrong asset.

Run it

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

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

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

Try next

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

Hooks & tapleaf compute

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

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

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

  • Hook blocks: on_resolved (per input) and on_pre_broadcast (per action), each running set assignments in declaration order.
  • Assignment targets: instance.X and params.X.
  • The tapleaf compute spec ("type": "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.

In the meantime, HookBlock and ParamCompute in the field reference describe both, and Issuing the NFTs works a real tapleaf-compute chain end to end.

Instance, state & constructors

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

Problem. Deploy a contract once and then act on it repeatedly — persisting the template's field values and tracking the live UTXO set across transactions.

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

  • Manifest, instance and state in practice, and what a wallet has to keep.
  • Contract templates: grouping actions under a typed contract with fields, and the instance.NAME namespace they populate.
  • Constructors: an action carrying a create_instance block is a constructor — there is no separate flag — and recording the instance ({ "instance": { "template", "fields" } }) with resolved field values.
  • The state: how covenant outputs are added and spent inputs removed after each broadcast.
  • provided_inputs: pre-filling a counterparty's UTXO inline (the website-to-wallet integration pattern).

In the meantime, a Last Will covenant builds a template, a constructor and an instance from scratch, and ContractTemplate and InstanceCreate in the field reference list the keys.

The lending protocol

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

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

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

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

The deal, in one paragraph

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

The cast

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

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

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 contract template, and its actions walk an instance through a sequence of states:

StateReached byMeaning
nfts_issuedIssueUtilityNFTsThe four NFTs exist; terms are encoded.
offer_openLockCollateralCollateral and NFTs sit behind pre_lock.
cancelledCancelOfferThe borrower withdrew before a lender took it.
loan_activeSetupLendingA lender funded the offer.
repaidRepayLoanThe borrower paid principal plus interest.
liquidatedLiquidateAfterExpiryThe deadline passed; the lender took the collateral.
settledClaimPrincipalWithInterestThe lender drew the repayment from the vault.

These state names are ours, not the format's. A manifest has nowhere to declare a state machine — no states list, no from/to on an action. The table above is prose, written to make this chapter readable.

What actually constrains the order is the chain. Each action's inputs name UTXO types that only the previous action creates, so SetupLending cannot run before LockCollateral has produced a pre_lock output to spend. Read the sequence off the covenants, which enforce it, rather than off a table, which cannot.

Rendered, those transitions are the protocol's flow:

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

Two of those arrows are unilateral escape hatches — CancelOffer and LiquidateAfterExpiry — that one party can take without the other's cooperation. That's the whole point of a trustless protocol: the exits don't depend on the counterparty playing along. RepayLoan is the cooperative happy path both sides want. Nothing in the manifest labels them as such; what makes an exit unilateral is that its covenant path needs only one party's key or NFT, which you can read off the action's witnesses.

How this chapter is organised

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

  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 offer — LockCollateral puts the collateral and NFTs on-chain behind the pre_lock covenant, with an op_return discovery beacon.
  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 and made a wallet first. Because this protocol has two roles, create two wallets and fund both from the testnet faucet:

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

Where's the funding address? Run 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 contract templates, their fields and actions; validate checks the manifest is internally consistent:

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

Then start with Issuing the NFTs.

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

Issuing the NFTs & encoding the terms

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

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

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

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

The constructor and its terms

IssueUtilityNFTs is the template's constructor: it carries a create_instance block, and that is what makes it one. Its params are the loan terms the borrower chooses:

"IssueUtilityNFTs": {
  "params": {
    "BORROWER_PUB_KEY":            { "type": "pubkey", "compute": { "type": "wallet", "wallet": "key" } },
    "COLLATERAL_ASSET_ID":         { "type": "liquid.asset_id" },
    "COLLATERAL_AMOUNT":           { "type": "u64" },
    "COLLATERAL_DECIMALS_MANTISSA":{ "type": "u8", "default": "8" },
    "PRINCIPAL_ASSET_ID":          { "type": "liquid.asset_id" },
    "PRINCIPAL_AMOUNT":            { "type": "u64" },
    "PRINCIPAL_DECIMALS_MANTISSA": { "type": "u8" },
    "PRINCIPAL_INTEREST_RATE":     { "type": "u16" },
    "LOAN_EXPIRATION_TIME":        { "type": "u32" }
  },
  ...
}

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

Minting four NFTs from issuance inputs

New to issuance? This is the first action in the book that mints assets. Asset issuance & NFTs covers the 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": { "instance.BORROWER_NFT_ASSET_ID": "asset" } }
}

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

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

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

Bit-packing the loan terms

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

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

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

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": {
  "fields": {
    "PRINCIPAL_OUTPUT_SCRIPT_HASH": {
      "type": "tapleaf", "simf": "./p2pk.simf",
      "params": { "PUB_KEY": { "type": "pubkey", "value": "BORROWER_PUB_KEY" } }
    },
    "LENDER_PRINCIPAL_COV_HASH": {
      "type": "tapleaf", "simf": "./asset_auth.simf",
      "params": {
        "ASSET_ID":        { "type": "liquid.asset_id", "value": "LENDER_NFT_ASSET_ID" },
        "ASSET_AMOUNT":    { "type": "u64",  "value": "1" },
        "WITH_ASSET_BURN": { "type": "bool", "value": "true" }
      }
    },
    "LENDING_COV_HASH": {
      "type": "tapleaf", "simf": "./lending.simf",
      "params": { "…": "…", "LENDER_PRINCIPAL_COV_HASH": { "type": "bytes32", "value": "LENDER_PRINCIPAL_COV_HASH" } }
    },
    "PRE_LOCK_COV_HASH": {
      "type": "tapleaf", "simf": "./pre_lock.simf",
      "params": { "…": "…", "LENDING_COV_HASH": { "type": "bytes32", "value": "LENDING_COV_HASH" } }
    },
    "…": "…"
  }
}

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

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

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

script_auth.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 action — LockCollateral, SetupLending, RepayLoan, and the rest — is run with --instance lending.instance.json so the wallet rebuilds the exact same covenant addresses without re-entering anything. This is the full template / instance model from Instance, state & constructors.

Run it

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

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

A testnet params file

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

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

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

  • BORROWER_PUB_KEY is absent on purpose — it's a wallet compute, 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 action reads back:

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

What to look at:

  • The four *_ASSET_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.

LockCollateral — publishing the offer

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

"LockCollateral": {
  "inputs": [
    { "id": "collateral_in",   "utxo_source": "wallet", "asset": "instance.COLLATERAL_ASSET_ID",
      "amount_sat": { "min_amount": "instance.COLLATERAL_AMOUNT" } },
    { "id": "borrower_nft_in", "utxo_source": "wallet", "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_in",   "utxo_source": "wallet", "asset": "instance.LENDER_NFT_ASSET_ID",   "amount_sat": 1 },
    { "id": "first_params_in",  "utxo_source": "wallet", "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" },
    { "id": "second_params_in", "utxo_source": "wallet", "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc" }
  ],
  "outputs": [
    { "id": "pre_lock_out",       "destination": { "utxo_type": "pre_lock" },            "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" },
    { "id": "borrower_nft_locked","destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_locked",  "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.LENDER_NFT_ASSET_ID",   "amount_sat": 1 },
    { "id": "first_params_locked",  "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID",  "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" },
    { "id": "second_params_locked", "destination": { "utxo_type": "prelock_script_auth" }, "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" },
    { "id": "indexer_op_return", "destination": { "type": "op_return" },
      "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)" },
    { "id": "collateral_change", "destination": "change", "asset": "instance.COLLATERAL_ASSET_ID", "optional": true },
    { "id": "fee_change",        "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

Two things worth pausing on:

  • The OP_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 on-chain, and only on-chain. The pre_lock covenant re-derives COLLATERAL_AMOUNT and rejects a spend that does not match. Nothing checks it before the build, so a mis-sized input costs you a failed dry-run rather than a friendly prompt — the covenant is the law, and it is also the only check.

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

Run it

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

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

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

Accepting or cancelling the offer

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

Phase 3 of the lending walkthrough. The offer is open. A lender accepts it with SetupLending, or the borrower withdraws it 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 action: SetupLending takes the accept path, CancelOffer the cancel path. An action picks its path with a PATH witness — Left(()) or Right(()) — the simplicityhl selector from Multiple spending paths. How each path is enforced on-chain is out of scope here; what's new for the manifest are two witness patterns this phase relies on, both visible in the JSON below:

  • SPEND_PATH — 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": "instance.PRINCIPAL_ASSET_ID", "required_index": 5,
      "amount_sat": { "min_amount": "instance.PRINCIPAL_AMOUNT" } },
    { "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true, "required_index": 6 }
  ],
  "outputs": [
    { "id": "lending_collateral_out", "destination": { "utxo_type": "lending_collateral" },  "required_index": 0, "asset": "instance.COLLATERAL_ASSET_ID", "amount_sat": "instance.COLLATERAL_AMOUNT" },
    { "id": "principal_to_borrower",  "destination": { "utxo_type": "p2pk" },                "required_index": 1, "asset": "instance.PRINCIPAL_ASSET_ID",  "amount_sat": "instance.PRINCIPAL_AMOUNT" },
    { "id": "first_params_relocked",  "destination": { "utxo_type": "lending_script_auth" }, "required_index": 2, "…": "…" },
    { "id": "second_params_relocked", "destination": { "utxo_type": "lending_script_auth" }, "required_index": 3, "…": "…" },
    { "id": "borrower_nft_released",  "destination": { "utxo_type": "lending_script_auth" }, "required_index": 4, "asset": "instance.BORROWER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "lender_nft_released",    "destination": "wallet",                                "required_index": 5, "asset": "instance.LENDER_NFT_ASSET_ID", "amount_sat": 1 },
    { "id": "principal_change", "destination": "change", "asset": "instance.PRINCIPAL_ASSET_ID", "optional": true, "required_index": -2 },
    { "id": "fee_change",       "destination": "change", "asset": "lbtc", "optional": true, "required_index": -1 }
  ]
}

Three details that make this work:

  • required_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 action adds a SIGNATURE witness sourced from BORROWER_PUB_KEY — and routes every NFT to an op_return to burn it. Here the outputs line up one-to-one with the inputs (no principal is injected), so no required_index is needed; the collateral returns to the borrower's wallet:

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

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

Test the cancel path too. The happy flow runs SetupLending; 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": "instance.PRINCIPAL_ASSET_ID", "amount_sat": "principal_in.amount_sat" },
    { "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
  ]
}

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

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

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

The lending covenant: repay or liquidate

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

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

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

RepayLoan — borrower settles (PATH::LEFT)

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

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

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

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

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

LiquidateAfterExpiry — lender seizes (PATH::RIGHT)

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

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

check_lock_height is the on-chain half of an nLockTime/CLTV timelock: the spend is only valid once the chain reaches LOAN_EXPIRATION_TIME. Note the mapping is the identity here (no payout injected) and the gating token is the Lender NFT, which the lender brings from their wallet — there's no signature, holding the NFT is the authorisation. The collateral lands in the lender's wallet; the NFTs burn. Liquidating early is refused by the chain, not by the wallet: check_lock_height in the covenant is what enforces the deadline, so trying before LOAN_EXPIRATION_TIME costs you a failed dry-run rather than a friendly warning. Nothing in the manifest re-states the deadline, and nothing should — a second copy could disagree with the covenant, and the covenant is the one that decides.

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

The principal vault: asset_auth.simf

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

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

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

ClaimPrincipalWithInterest — lender withdraws

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

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

The two index witnesses are what let the covenant find its NFT. asset_auth checks that a specific asset is present at a specific input and burned at a specific output, so it has to be told which positions those are — INPUT_ASSET_INDEX is 1 because lender_nft_in is the second input, and OUTPUT_ASSET_INDEX is 1 because lender_nft_burned is the second output.

These are literals, and that couples the manifest to its own layout. There is no formula that resolves an id to its final position; the indices are written by hand, as u32 values, against the order the inputs and outputs are declared in. Reorder either list and you must renumber these witnesses, or the covenant looks at the wrong slot and the spend fails the dry-run. Count from the declarations above before changing them.

With the vault swept, the loan is fully wound down.

Why a vault instead of paying the lender directly — and "accumulation." The asset_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 template / instance model.

For the precise rules behind anything here, see the manifest field reference.

Manifest field reference

Every field the manifest format defines, generated from schema/txmanifest.schema.json — the schema the reference wallet emits from the types it parses with, so this page cannot drift from the engine. See schema/README.md for the exact revision it was taken from.

A field not listed here is a parse error, not an ignored key. Two exceptions apply everywhere and are not repeated per type: $comment (documentation a tool must ignore) and $schema (an editor hint), both legal on any object at any depth.

For what the type strings mean — u64, pubkey, liquid.asset_id — see Field types.


Manifest (root)

FieldTypeRequiredDefault
actionsmap of string → Actionno—
chainstringno—
contract_templatesmap of string → ContractTemplateno—
descriptionstringno—
manifest_versionstringyes—
protocolstringyes—
simplicity_hlSimplicityHlno—
utxo_typesmap of string → UtxoTypeno—

actions — map of string → Action

Standalone actions that require no template instance (e.g. Prepare).

chain — string

No description in the schema.

contract_templates — map of string → ContractTemplate

Contract template definitions. Each template has typed fields and actions. An action carrying a create_instance block is a constructor for its template.

description — string

No description in the schema.

manifest_version — string, required

Version of the manifest format this file is written against, as specified by ELIP-205 — not the version of any tool that reads it. Checked against FORMAT_VERSION at parse time; see check_format_version for the compatibility rule.

protocol — string, required

No description in the schema.

simplicity_hl — SimplicityHl

SimplicityHL toolchain settings for this manifest's .simf programs.

utxo_types — map of string → UtxoType

No description in the schema.


Action

FieldTypeRequiredDefault
allow_changeAllowChangeno—
create_instanceInstanceCreateno—
descriptionstringno—
inputsarray of Inputno—
intentstringno—
on_post_broadcastHookBlockno—
on_pre_broadcastHookBlockno—
outputsarray of Outputno—
paramsmap of string → ParamDefno—

allow_change — AllowChange

Whether the engine may append a change output this action did not declare.

Every output a transaction carries must be written in the manifest. The network fee is the single exception, because it has no manifest spelling. A change output is not an exception: its address and amount are chosen by the engine, so silently adding one moves value to a destination the manifest never named, in an amount nobody wrote down. That is how an oversized collateral input once turned 88,735 satoshis into a miner's fee without a word of warning.

So the default is AllowChange::None: a surplus in any asset — including L-BTC — is an error, and the action must size its inputs to what it spends. Relax it only where the surplus genuinely cannot be predicted:

  • "none" (default) — no change may be added; any surplus is an error.

  • "lbtc_only" — the engine may return an L-BTC surplus to the wallet. Use this for ordinary funding actions, where the fee is only known after the size is. A surplus in any other asset is still an error.

  • "any" — the engine may return a surplus in any asset.

This governs undeclared change. An output with "destination": "change" is declared, and permits change for its own asset regardless of this setting.

create_instance — InstanceCreate

Constructor-only: defines the new instance written to the instance file.

description — string

No description in the schema.

inputs — array of Input

No description in the schema.

intent — string

One-line statement of what this action does, shown as the first clear-signing screen. Supports {ref} and {ref:symbol} interpolation against the execution context (see preview::interpolate); asset-typed refs must carry :symbol so a wallet can substitute a friendly name (enforced by validate::check_ui).

Named for the intent field in Ethereum's ERC-7730 clear-signing metadata, which plays the same role. Author-supplied, so only as trustworthy as the manifest's own signature chain — never a substitute for what a hardware device verifies. It IS covered by the registry hash (see crate::canonical).

on_post_broadcast — HookBlock

Method-level hook: runs after broadcast (captures txids, asset IDs).

on_pre_broadcast — HookBlock

Method-level hook: runs after inputs are resolved, before PSET is built.

outputs — array of Output

No description in the schema.

params — map of string → ParamDef

Runtime action parameters (Spec §5). Prompted, or set by hooks.


AllowChange

Which assets an action lets the engine return a surplus in, via a change output the manifest did not declare. See Action::allow_change.

Spelled as an enum rather than a boolean because the useful middle case — "return leftover L-BTC, but never move a protocol asset I did not account for" — is the one most funding actions want, and a boolean cannot say it.

  • "none" No undeclared change. A surplus in any asset fails the build.

  • "lbtc_only" Only the policy asset (L-BTC) may be returned.

  • "any" Any asset may be returned.


BlindingFactors

The blinding factors of one confidential output or input.

A wallet normally draws both factors at random, which is right when nothing but the receiver ever reads them. It is wrong when a covenant reads them: a program that checks its own outputs' commitments (deadcat_v3 requires each recreated reissuance token to advance both factors by exactly one) can only be satisfied by factors the spender chose deliberately. Elements' blind_last offers no way to say which, so the engine runs its own blinding pass whenever this field appears.

Each factor is a 32-byte scalar written as a small decimal ("1"), a 0x-prefixed hex string of up to 64 chars, or a reference (params.X, instance.X) resolving to either — which is how a factor an operator reads off an explorer or a side file reaches the build.

On an output it pins what the builder would otherwise choose. Omitting one leaves it random; omitting both makes the field a no-op. One confidential output must keep a free value_bf: the transaction's blinding factors have to sum to zero and the builder solves the last free one to make that true, so pinning every one of them leaves the transaction unbalanceable. In practice that free output is the change.

On a covenant input it is not a choice but a statement of fact — the factors the UTXO being spent was created with. They are what lets the engine rebuild the confidential prevout the sighash and the introspection jets need, and (for a reissuance) the assetBlindingNonce Elements demands. Both halves are required, and a wrong value is caught before signing: the rebuilt commitments simply will not be the ones on chain.

The factors are public to anyone who reads them here, so this trades the output's confidentiality for reissuability: it hides nothing, it only keeps the commitment well-formed. Elements has no explicit reissuance token (confidential_validation.cpp rebuilds the spent token's generator from the blinding nonce and byte-compares it), so a token that must stay reissuable must stay blinded, with a factor its next spender can reproduce.

FieldTypeRequiredDefault
asset_bfanyno—
value_bfanyno—

asset_bf — any

Asset blinding factor (abf). Also the value Elements requires as the assetBlindingNonce of any later reissuance spending this output.

value_bf — any

Value blinding factor (vbf).


ComputeSpec

How a value is computed: either a plain expression string or a structured spec.

Used in two places, deliberately the same shape: create_instance.fields values and ParamDef::compute.

Hand-deserialized rather than #[serde(untagged)], for the same reason as UiSpec: untagged collapses every inner failure into data did not match any variant of untagged enum ComputeSpec, which hides the one thing the author needs to know. Dispatching on the JSON shape lets ParamCompute's error — naming the offending key or the unknown type — reach the surface.

  • string Simple expression: "$params.COLLATERAL_ASSET_ID", "instance.DEBT - 1".

  • object Structured compute — tapleaf, simf_fn, or an explicit expr.


ContractTemplate

A contract template: typed field declarations and named methods.

FieldTypeRequiredDefault
actionsmap of string → Actionno—
descriptionstringno—
fieldsmap of string → FieldDefno—

actions — map of string → Action

Actions callable on an instance of this template. Structurally identical to the top-level actions — the only difference is that these run against an instance, so their formulas may reference instance.*. An action carrying a create_instance block constructs a new instance of this template.

description — string

No description in the schema.

fields — map of string → FieldDef

Field declarations — names and types only. Values are set by constructors.


FieldDef

A field declaration inside a contract template. Just a name and type; no compute here.

FieldTypeRequiredDefault
defaultstringno—
descriptionstringno—
typestringyes—

default — string

No description in the schema.

description — string

No description in the schema.

type — string, required

No description in the schema.


HookBlock

A hook: a flat map of setter targets to the values they take.

One type serves every hook position — an action's on_pre_broadcast / on_post_broadcast and an input's on_resolved — because they only ever differed in when they run, never in shape.

Targets use dot-path notation: "instance.FOO" — sets a contract-template field "params.FOO" — sets an action param

Values are ComputeSpec, the same type create_instance.fields uses, so all three "name → how to produce a value" maps in the format read alike. In hook position only the expression forms are meaningful; validate rejects the rest (see validate::check_hook).

Within an input's own on_resolved, two bare keywords are self-referential: "asset" resolves to that input's computed issuance asset ID (or its UTXO asset for non-issuance inputs), and "reissuance_token" to the computed reissuance token asset ID.

FieldTypeRequiredDefault
setmap of string → ComputeSpecyes—

set — map of string → ComputeSpec, required

No description in the schema.


Input

FieldTypeRequiredDefault
amount_satanyno—
assetanyno—
blindingBlindingFactorsno—
descriptionstringno—
from_addressstringno—
idstringyes—
issuanceanyno—
on_resolvedHookBlockno—
optionalbooleanno—
required_indexintegerno—
sequenceanyno—
uiUiSpecno—
utxo_sourceanyyes—
witnessesanyno—

amount_sat — any

No description in the schema.

asset — any

No description in the schema.

blinding — BlindingFactors

The blinding factors of the covenant UTXO this input spends, when it is confidential. Both halves are required. See BlindingFactors.

description — string

No description in the schema.

from_address — string

For utxo_source: "wallet" inputs: constrain coin selection to UTXOs whose scriptPubKey equals this address's. A reference (instance.X / params.X) or a literal address string. Use this to pin an input to a committed address — e.g. so a covenant's collateral is spent from the exact address whose hash it commits to.

id — string, required

No description in the schema.

issuance — any

An Elements asset issuance carried by this input.

  • {"kind": "new", "asset_amount_sat": <expr>, "inflation_amount_sat": <expr>} — mint a brand-new asset, whose id is derived from this input's outpoint. Either amount may be 0 (reissuance tokens only, or a fixed supply with no reissuance rights). - {"kind": "reissue", "asset_amount_sat": <expr>, "entropy": <ref>} — mint more of an existing asset by spending its reissuance token.

A reissuance needs the issuance entropy of the original mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value the asset id itself is derived from. It cannot be recovered from anything on chain: the reissuance token UTXO carries no trace of the outpoint that created it. So a constructor has to capture it at the one moment it exists, and hand it back later:

json // in the minting action's create_instance: "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy" // in the reissuing action's input: "issuance": { "kind": "reissue", "asset_amount_sat": "params.PAIRS", "entropy": "instance.YES_ISSUANCE_ENTROPY", "issued_asset": "instance.YES_TOKEN_ASSET" }

issued_asset is optional and is a check, not an input: the engine re-derives the asset id from the entropy and refuses to build if the two disagree. An entropy is opaque, and the byte order block explorers print is the reverse of the one used here — without the check a transposed value still builds a broadcastable transaction that reissues the wrong asset.

Failing that, the entropy may come from provided_inputs.<input_id>.issuance_entropy in the instance file. That works, but it travels with an outpoint override which pins the input for every action sharing its id — long after the pin is correct.

on_resolved — HookBlock

Inline hook evaluated after this input's UTXO is resolved and its issuance attrs (asset, reissuance_token) are computed.

optional — boolean

When true, the transaction proceeds even if this UTXO is not found. Spec §6.

⚠️ Parsed but NOT enforced — the engine has no optional-input path, so a missing UTXO fails resolution regardless. examples/dex marks its fee_input optional and does not get that behaviour.

required_index — integer

Required transaction input index: 0-based absolute, or negative to count from the end (-1 = last). Spec §6.

⚠️ Parsed but NOT enforced. Nothing in the engine reads this field; inputs land in declaration order and that ordering happens to satisfy the covenants. Manifests assert an index here 106 times and none of it is checked, so a reordering that breaks a covenant's introspection would surface only as an on-chain failure. See validate.rs for where a static check belongs.

sequence — any

Per-input nSequence. Drives BIP68 relative timelocks (the check_lock_distance / check_lock_duration Simplicity jets). Accepts:

  • {"relative_blocks": <expr>} — block-based relative lock (≤ 65535 blocks)
  • {"relative_seconds": <expr>} — time-based relative lock, rounded up to 512s units - a bare integer / expression — raw nSequence value

Omitted → the input stays at Sequence::MAX (relative locktime disabled).

ui — UiSpec

Clear-signing UI hint for this input (net-effect debit line).

utxo_source — any, required

"wallet" or {"utxo_type": "..."} or conditional object

witnesses — any

Simplicity witnesses for this input: map of witness name → definition.

Must name every witness the input's program declares, and nothing else. A definition is either an object carrying a type — simplicityhl (a concrete value), Signature (a BIP340 signature the engine computes), taproot_leaf (a leaf selector, which is not a program witness and so is exempt from both halves of that rule) — or the bare string "unused" for a witness this spending path does not depend on, which supplies the zero its pruned branch wants.

Nothing is inferred from an omission. Anything left out is an error, at validate time against the .simf and again at run time against the compiled program.


InstanceCreate

Describes the new instance written after broadcast.

An action carrying this block is a constructor — there is no separate flag. The instance is always of the contract template the action is declared in, so the template is not named here: create_instance is only legal inside contract_templates.<T>.actions.*, and always creates a <T>.

FieldTypeRequiredDefault
fieldsmap of string → ComputeSpecyes—

fields — map of string → ComputeSpec, required

Maps field names to their initial values. Each value is either a string expression ("$params.FOO") or a compute spec ({ "compute": "tapleaf", ... }).


Output

FieldTypeRequiredDefault
amount_satanyno—
assetanyno—
blindingBlindingFactorsno—
conditionstringno—
confidentialbooleanno—
dataanyno—
descriptionstringno—
destinationOutputDestinationyes—
idstringyes—
optionalbooleanno—
required_indexintegerno—
uiUiSpecno—

amount_sat — any

No description in the schema.

asset — any

No description in the schema.

blinding — BlindingFactors

Pin this confidential output's blinding factors instead of letting the builder pick them. See BlindingFactors.

condition — string

No description in the schema.

confidential — boolean

Whether this output is blinded. The only place confidentiality is declared: a utxo_type describes an address, and two outputs paying the same covenant address need not agree — deadcat_v3's state-1 address holds blinded reissuance tokens beside an explicit collateral UTXO, because the program introspects one as a Pedersen commitment and the other as a plain amount.

Defaults to true for wallet and address destinations on Liquid, and to false for covenant (utxo_type) destinations, where a Simplicity program usually has to read the value and asset. true on a covenant output is not supported yet and is an error rather than a silent downgrade — the address it produces would be right and the UTXO at it unspendable by the paths that expect a commitment.

data — any

OP_RETURN payload, for destination: {"type":"op_return"} outputs. Either a concat(ref, …) string, or an object {"parts": [ … ]} of typed fields (for exact binary layouts — LE integers, program_id, asset-internal bytes). Evaluated to raw bytes and embedded after OP_RETURN. Omit for a bare data-less OP_RETURN (NFT burns).

description — string

No description in the schema.

destination — OutputDestination, required

Where this output's value goes. See OutputDestination for the accepted forms.

id — string, required

No description in the schema.

optional — boolean

No description in the schema.

required_index — integer

Required transaction output index; same semantics and same caveat as Input::required_index (Spec §7) — parsed, never enforced.

ui — UiSpec

Clear-signing UI hint for this output (net-effect credit line).


OutputDestination

Where this output's value goes. A string is change (wallet change, amount auto-computed), wallet (a fresh receive address), or an address / params.X reference resolving to one.

  • string change, wallet, a literal address, or a params.X / instance.X reference that resolves to one.

  • object The covenant address derived for a declared utxo_type.

    Fields: args?, compile_params?, utxo_type — ? marks an optional field.

  • object P2TR output built from a 32-byte script hash.

    Fields: script_hash

  • "type": "op_return" op_return / burn embed the output's own data field (bare OP_RETURN when absent). fee declares the fee leg and produces no PSET output of its own.

  • object Conditional destination. Parsed but NOT implemented — the engine has no arm for it and skips the output entirely.


ParamCompute

Auto-computation spec for a derived compile param or action param.

Dispatched by type, the same discriminator every other tagged object in the format uses (script.type, destination.type, a witness's type). Note this is the method of computation; the value's data type is ParamDef::type_, one level up. The legacy key lang is still accepted as an alias for the discriminator:

  • "expr": arithmetic expression over other compile params (pow(base, exp) supported)
  • "tapleaf": compile a .simf file and return its Simplicity tapleaf hash (32 bytes hex)
  • "simf_fn": call a named function in a .simf file and use its return value
  • "wallet": take the value from the executing wallet rather than the manifest, with wallet selecting which (WalletValue)

The wallet variant differs from the others in kind: expr, tapleaf and simf_fn are reproducible by anyone holding the manifest, whereas a wallet_* value depends on who is running the action. They live here anyway because from an author's point of view they answer the same question — where does this value come from, if not the user? — and having two fields for that (the old source) meant two things to check and a name that collided with script.source, a file path.

  • "type": "expr"

    Fields: expr

  • "type": "tapleaf"

    Fields: depends_on?, extra_leaves?, params?, simf — ? marks an optional field.

  • "type": "script_hash" sha256(scriptPubKey) of an address — the exact value the Simplicity output_script_hash / input_script_hash jets return for a UTXO paying it.

    An address and its script hash are two views of one destination: the covenant commits to the hash, the transaction pays to the address, and if they ever disagree the spend fails on-chain. Deriving one from the other is the only way to keep that true — a manifest that asks for both separately is asking to be given two values that must match and cannot be checked.

    Blinding is irrelevant here: a confidential address has the same scriptPubKey as its unconfidential form, so both hash alike (script_hash_of_address pins this).

    Fields: address

  • "type": "hook" A value a hook supplies later in this run — declared here, set by an on_resolved / on_pre_broadcast block targeting params.<name>.

    This exists so a hook cannot invent an identifier. Without it, "set": { "params.YES_TOKN_ASSET": "asset" } is accepted, fills a slot nobody reads, and surfaces as a wrong covenant address much later; with it, validate rejects the typo and the declaration carries the type that byte-order handling depends on.

    It lives under compute rather than as a separate deferred: true flag because compute already means exactly "this value is derived, do not prompt for it" — the only thing that differs here is who derives it. A second flag would need its own prompt-suppression path and would have to define what it means alongside a compute that is also present.

  • "type": "wallet" A value taken from the executing wallet rather than the manifest.

    Grouped under one tag rather than spread across three so that "is this wallet-derived?" is a single check on compute before dispatching on wallet — and so adding a new wallet-derived value does not grow the top-level variant list.

    Fields: wallet

  • "type": "simf_fn" Call a named function in a .simf file after inputs are resolved. The function is compiled with compile_params as param:: constants. Its runtime input is read from input (a dot-path into ctx, e.g. "params.STATE_BYTES"). The return value is stored as the param value.

    Fields: compile_params?, fn?, input?, simf — ? marks an optional field.


ParamDef

FieldTypeRequiredDefault
computeComputeSpecno—
defaultstringno—
descriptionstringno—
typestringyes—

compute — ComputeSpec

How this param's value is derived. When present the user is never prompted.

Either a bare expression string — "instance.PRINCIPAL_AMOUNT * 2" — or a structured spec for the cases an expression cannot express (tapleaf, simf_fn). The bare form is what formula used to be; they were two ways to say "this value is computed, do not ask", so they are now one.

default — string

Default value shown as a pre-fill in the prompt.

description — string

No description in the schema.

type — string, required

No description in the schema.


SimplicityHl

SimplicityHL toolchain settings — how the .simf programs are compiled, as distinct from what the protocol does.

Deliberately carries no compiler-version field. SimplicityHL has its own simc "<range>"; source directive, which the compiler enforces fail-fast before lexing, across the entry file and every reachable dependency — none of which a manifest key can do. Tooling that wants the requirement without compiling can read it via version::SimcDirective::requirement_of. Declaring it here as well would only create a second place to disagree.

FieldTypeRequiredDefault
debug_symbolsbooleannofalse
unstable_featuresarray of UnstableFeatureNameno—

debug_symbols — boolean, default false

Whether covenant .simf programs are compiled with debug symbols included.

This changes the program's CMR and therefore every covenant address, because assert!/panic! embed source info into fail-node commitments. Set it to match the toolchain of any protocol this manifest must interoperate with — e.g. true for simplicity-lending / smplx-sdk, which compiles with debug symbols on.

Defaults to false (production; debug symbols are a transitional feature).

unstable_features — array of UnstableFeatureName

Unstable SimplicityHL compiler features this manifest's programs are allowed to use — the manifest form of simc -Z <name>, one entry per feature:

json "simplicity_hl": { "unstable_features": ["enums"] }

The compiler rejects gated syntax unless the feature is enabled, so a program using enum fails to compile until "enums" is listed here. Enabling a feature the programs don't use is harmless: this only lifts a restriction, it never changes generated code, and therefore never changes a CMR or covenant address.

Manifest-wide rather than per-utxo_type, mirroring simc's own per-invocation -Z flag — the whole point of a gate is that a reader can see, in one place, which unstable syntax this protocol depends on.

Defaults to empty: nothing unstable is enabled.


TapleafParam

A single entry in a ParamCompute::Tapleaf params map. Combines the value reference (compile-param name or literal) with an optional type hint.

FieldTypeRequiredDefault
typestringno—
valuestringyes—

type — string

Manifest type, e.g. "liquid.asset_id", "u64", "bool". When absent, the type is inferred from the compile-param of the same name.

value — string, required

A compile-param name reference OR a string literal like "1", "true".


TaprootLeafKind

The hashing scheme for a TaprootLeafSpec's payload.

  • "tapdata" Elements taproot data leaf — the only scheme the engine implements.

TaprootLeafPayloadItem

One item of a taproot leaf payload. Items are concatenated, in order, into the bytes that get hashed as the leaf.

  • string Hex literal taken as raw bytes, e.g. "0x01". Whole bytes only.

  • "type": "u8" Computed value, resolved against the run's params/instance fields and encoded per type / endian / pad_to.

    Fields: align?, endian?, pad_to?, value — ? marks an optional field.

  • object Reference to a state_vars entry; its default_value is encoded as a single u8.

    Fields: state_var


TaprootLeafSpec

Describes one additional taproot leaf appended to the Simplicity program leaf.

Each leaf's payload is hashed as tapdata — SHA256(SHA256("TapData") ‖ SHA256("TapData") ‖ payload), which is the value a program computes with jet::tapdata_init(), sha_256_ctx_8_add_* and finalize — then folded into the tap tree with TapBranch/elements in declaration order, matching jet::build_tapbranch. The payload's width must match what the .simf hashes: sha_256_ctx_8_add_32 wants exactly 32 bytes, add_8 exactly 8. A mismatch yields a perfectly valid address that the covenant then refuses to recognize as its own.

FieldTypeRequiredDefault
payloadarray of TaprootLeafPayloadItemyes—
typeTaprootLeafKindyes—

payload — array of TaprootLeafPayloadItem, required

Ordered payload items, concatenated into this leaf's byte string.

type — TaprootLeafKind, required

How the payload is hashed. Only tapdata is implemented, and it was previously accepted as a free string — so any other spelling was silently hashed as tapdata anyway, producing an address whose derivation nobody had written down.


UiDetail

FieldTypeRequiredDefault
groupstringno—
hidebooleannofalse
labelstringno—
rolestringno—

group — string

Override the net-effect account/bucket heading (else derived from source/destination).

hide — boolean, default false

Suppress this leg from the net-effect diff (e.g. pure protocol data).

label — string

Human-readable one-line description of this leg — the only signer-facing text for it (description is not a fallback; see preview::input_label).

Capped at crate::validate::MAX_UI_LABEL characters so it fits one net-effect row alongside the amount and asset symbol. The cap reaches the schema as a maxLength — injected by crate::schema from that constant rather than written here as a literal, so the two cannot drift — and an editor flags an over-long label while typing rather than at validate time.

role — string

Optional semantic tag (e.g. "collateral", "auth_nft").


UiSpec

Per-input / per-output UI hint. Accepts either a bare label string ("collateral locked") or a detailed object for finer control.

Hand-deserialized rather than #[serde(untagged)]: an untagged enum reports only data did not match any variant of untagged enum UiSpec, swallowing the real reason. Dispatching on the JSON shape lets UiDetail's own error through, so a misspelled key names itself.

  • string Shorthand for { "label": "..." }.

  • object

    Fields: group?, hide?, label?, role? — ? marks an optional field.


UnstableFeatureName

Unstable SimplicityHL compiler feature (simc -Z <name>).

  • imports — Module system syntax: 'use' imports, 'mod' modules, 'as' aliases, 'crate::' paths
  • enums — Enum syntax: 'enum' declarations and 'EnumName::Variant' match expressions

One of: "imports", "enums"


UtxoParamDef

One entry of a UtxoType::params interface.

FieldTypeRequiredDefault
defaultstringno—
descriptionstringno—
typestringyes—

default — string

Value to use when a site binds no args entry for this param.

Evaluated in instance scope: a literal, or instance.X naming a field fixed when the contract was instantiated. Action scope is deliberately unreachable — a value that varies per run is exactly what a site must bind explicitly.

Without a default, every site must bind it, and validate says which ones don't.

description — string

No description in the schema.

type — string, required

Manifest type, used as the compile-param type hint (u64, bytes32, liquid.asset_id, …) — the same vocabulary action params use.


UtxoScript

FieldTypeRequiredDefault
compile_paramsmap of string → stringno{}
extra_leavesarray of TaprootLeafSpecno—
sourcestringno—
typestringyes—

compile_params — map of string → string, default {}

Per-utxo-type compile param remappings: simf_param_name → compile_param_reference. e.g. { "SCRIPT_HASH": "LENDING_COV_HASH" } passes the value of LENDING_COV_HASH to the simf as SCRIPT_HASH.

extra_leaves — array of TaprootLeafSpec

No description in the schema.

source — string

No description in the schema.

type — string, required

No description in the schema.


UtxoType

FieldTypeRequiredDefault
assetstringno—
descriptionstringyes—
paramsmap of string → UtxoParamDefno—
scriptUtxoScriptno—
state_varsanyno—

asset — string

No description in the schema.

description — string, required

No description in the schema.

params — map of string → UtxoParamDef

This type's parameter interface — everything the address derivation may read.

Declaring it switches the type to a closed scope: script.compile_params and extra_leaves resolve params.X against these params and nothing else. A site binds them with args ({"utxo_type": "t", "args": {"STATE": "params.x"}}), whose values are expressions evaluated in the action's scope.

Without it, the type keeps the legacy behaviour: leaves and compile params resolve against whatever is ambient at each mention. That is what makes one utxo_type derive two different addresses in two actions — params.foo means one thing where the action declares foo and something else where it does not — with no error, because an address is a hash and a wrong one looks exactly like a right one.

script — UtxoScript

No description in the schema.

state_vars — any

No description in the schema.


WalletValue

Which wallet-derived value a ParamCompute::Wallet spec resolves to.

  • "key" The wallet's x-only BIP340 pubkey. The wallet chooses the derivation path.

  • "script_hash" sha256(scriptPubKey) of the wallet's index-0 explicit output — the committed payout target a covenant checks repayment against.

  • "address" The explicit address matching WalletValue::ScriptHash. The two are a pair: the covenant commits to the hash, the wallet receives at the address, so they must be derived together.

Field types

The type string on a contract template's fields and an action's params.

This one table is maintained by hand: the JSON Schema types these as plain string, so unlike the manifest field reference it cannot be generated. It tracks manifest_to_simf_type and prompt.rs in the reference wallet.

Types a covenant can take

These map onto a SimplicityHL primitive, so a value of one of these types can be wired into a .simf program through script.compile_params.

Type stringSimplicityHL typeWritten asNotes
u8u8decimal string
u16u16decimal stringliquid.u16 is an accepted alias
u32u32decimal string
u64u64decimal string
boolbool"true" / "false"u1 is an accepted alias
bytes32u25664 hex charsRaw 32 bytes, passed through unchanged
pubkeyu25664 hex chars32-byte x-only BIP340 key. Not byte-reversed
liquid.asset_idu25664 hex charsByte-reversed before it reaches the program — see below

liquid.asset_id is the only type that reverses. Asset IDs are displayed in the same backwards order as a txid, so the engine reverses them into natural byte order before handing them to SimplicityHL. That reversal is driven by the declared type and nothing else — the parameter's name is never consulted. A 32-byte asset ID declared as bytes32 reaches the covenant byte-swapped relative to a liquid.asset_id, and produces a different, silently wrong address. When a value is an asset ID, say so.

Declaring a type is not optional in practice. Without one the engine falls back to inferring from the name and then the value, refuses to treat anything as an asset ID, and skips the parameter entirely if it cannot decide — which surfaces much later as a covenant address that does not match.

Types only a param can take

Valid on an action param, but with no SimplicityHL mapping, so they cannot be compiled into a covenant. Use them for values the transaction builder consumes.

Type stringWritten asNotes
addressbech32 / blech32 stringA Liquid or Elements address, e.g. a payout destination

Any other string is accepted as a free-text param and prompted for as text. asset_id and u256 get a formatting hint at the prompt but no covenant mapping — prefer liquid.asset_id and bytes32, which do.

Where the values live

Field values are stored in the instance as strings, whatever their type: integers as decimal, byte types as hex. See Instance, state & constructors.

Formula language reference

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

Operators

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

References

SyntaxDescription
instance.NAMEContract-template field value, from the instance
params.NAMEAction parameter 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
asset(inside an input's own on_resolved only) that input's asset, including one it has just issued
reissuance_token(same) the matching reissuance-token asset
output_id.amount_satSatoshi amount of a constructed output (post-construction)
feeEstimated transaction fee (used in change and re-lock formulas)

The $ prefix

Two spellings of a reference exist, and they are not interchangeable:

SpellingWhereMeaning
params.NAME, instance.NAMEinside a formula — amount_sat, a compute expression, a hook set valuea term in an expression that is evaluated
$params.NAME, $instance.NAME, $inputs.ID.FIELDas a whole value — a create_instance.fields entry, a witness keytake this value as-is; no arithmetic

The rule of thumb: $ means "this entire value is that reference". Where a string is parsed as an expression, write the bare form and the evaluator resolves it; where a string stands for one value and nothing more, the $ says so.

"create_instance": {
  "fields": {
    "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT",
    "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.RATE / 10000"
  }
}

Both forms are legal in create_instance.fields: the first copies a resolved param straight through, the second is an expression that happens to live there.

$inputs.<input_id>.<field> reaches a resolved input — most usefully $inputs.<id>.issuance_entropy, the value a reissuance later needs, and $inputs.<id>.issued_asset. It has no bare equivalent; an input's amount and asset are reached in formulas as input_id.amount_sat and input_id.asset.

Functions

FunctionSignatureDescription
pow(base, exp)(u64, u64) → u64Integer exponentiation
concat(a, b, …)(bytes…) → bytesByte concatenation — OP_RETURN data only, not a general formula function

There is no way to resolve an input or output id to its transaction index. A covenant that needs to be told a position (asset_auth's INPUT_ASSET_INDEX, for instance) takes a literal u32 witness, written by hand against the order the inputs and outputs are declared in. See Settling the loan.

Comparison and boolean operators exist but have nowhere to be used on their own: there is no rule block that takes a predicate. They are there for the conditional parts of arithmetic, not for validation.

CLI reference

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

Commands

validate <manifest>

Statically check a manifest and report obvious problems — without touching the network, wallet, or filesystem. Catches unknown utxo_type references, outputs missing a required amount_sat, duplicate input and output ids, malformed destinations, hooks writing to targets nothing declares, unreferenced UTXO types, and clear-signing text a wallet could not render. Exits non-zero if any errors are found (warnings alone still exit zero).

Note that a misspelled or unknown field does not reach validate: it is a hard parse error, raised by any command that reads the manifest at all. So is a manifest_version from a different format revision.

txw validate examples/p2pk/txmanifest.json

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

describe <manifest>

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

txw describe examples/lending/txmanifest.json

run <manifest> <action>

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

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>noneInstance file to read (template field values locked at deploy). Never auto-discovered — pass it for any action that reads instance.*.
--state <file>noneState file to read, locating covenant UTXOs. Never auto-discovered — pass it for any action that spends one.
--instance-out <file><stem>.instance.N.jsonWhere a constructor writes the new instance.
--state-out <file><stem>.state.N.jsonWhere the run writes state after broadcast.
--input <id>=<txid>:<vout>—Pin one input to a specific outpoint. Repeatable; outranks the state file.
--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.

Neither --instance nor --state has a default. Leave them off and the run starts with no instance and no state — which is right for a first action, and wrong for every action after it. Outputs are written to a fresh numbered file (txmanifest.state.1.json, then .2.json, …) so a run never overwrites the file it read. Pass the file the previous run wrote.

Every run also appends to <stem>.state.history.json, which is not versioned.

create-wallet

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

info

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

sync

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

get-balance

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

prepare <manifest> <action>

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

split

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

config [key] [value]

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

Typical session

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

Wallet implementation guide

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

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


Lifecycle

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

1. Parse

Read the target action's params. For an action declared inside a contract template, also load that instance's fields — those are reached as instance.NAME. A param carrying a compute block is never prompted for; nor is one already fixed by provided_inputs.params.

2. User supplies the remaining params

Prompt for every param not covered by the previous step. Present each param's description as guidance text.

params is the only runtime value namespace — every value the user supplies or a hook computes lives there.

3. Input selection

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

  • For ambiguous cases (multiple candidates) or when auto-select is disabled by wallet policy, prompt the user to choose.
  • Validate each provided_inputs UTXO against chain state: confirm it is unspent and its script_pubkey matches the expected script.
  • A pinned outpoint reads its amount and asset from the chain, which outrank anything the manifest or the operator supplies.

4. Hooks run

As each input resolves, run that input's on_resolved block. Once every input is resolved, run the action's on_pre_broadcast block. Hooks run in declaration order — the order they appear in the file — which is the only ordering guarantee.

A hook is a set map of target → value, written in the formula language rather than SimplicityHL. Targets are instance.NAME (write a contract-template field) or params.NAME. Within an input's own on_resolved, the bare keyword asset resolves to that input's asset — including one it has just issued — and reissuance_token to the matching reissuance token.

The context available to a hook is:

  • Resolved input outpoints (txid, vout), amounts, and assets for every input resolved so far.
  • All instance and params values set so far, including those set by earlier hooks in the same pass.

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

5. Computed params resolved

Evaluate every param whose compute block has not yet produced a value:

compute.typeProduces
bare string, or exprThe value of a formula-language expression
tapleafA covenant script hash, by compiling a .simf
script_hashsha256(scriptPubKey) of an address
walletA wallet-derived key, script_hash or address
simf_fnThe return value of a named function in a .simf
hookNothing — the value was supplied by a hook in step 4

Where two covenants each commit to the other's hash, seed with 32 zero bytes and iterate to convergence.

6. Outputs constructed

Build the transaction outputs from the action's outputs array. Evaluate each amount_sat formula against the now-complete context — instance, params, and resolved input amounts — and resolve each destination to a concrete scriptPubKey. Blind any output marked confidential, honouring a blinding block where one pins the factors.

7. Fee rate chosen and applied

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

A surplus that no declared output absorbs is governed by the action's allow_change, which defaults to "none": the action must size its inputs to what it spends, and an unexplained remainder is an error rather than a silent change output.

8. Fee review / adjustment → PSET created

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

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

9. Wallet signs

Populate witnesses into the PSET from each input's witnesses map — witnesses satisfy a specific input's script, so they are declared on the input, not on the action. The map must name every witness the input's program declares and nothing else; a witness this spending path does not use is written as the bare string "unused". Pre-computed witnesses from provided_inputs.witnesses are included verbatim.

10. Simplicity dry-run

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

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

The dry-run is where a manifest's assumptions are actually tested — nothing earlier in the lifecycle checks business rules. It is a cryptographic and covenantal check: it confirms the on-chain scripts will accept the constructed transaction.

11. Broadcast

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

12. Post-broadcast

Run the action's on_post_broadcast block, which is where values only known once the transaction exists — txids, newly issued asset IDs — are captured. Then update the contract's state atomically with respect to the broadcast: remove spent UTXOs, add the new covenant outputs, and persist the new instance if the action carried a create_instance. Where that data lives is your choice — a file, a database, browser storage — but it must survive to the next action.

Execution context for SimplicityHL code

The following are available to all SimplicityHL code at build time:

AvailableDescription
Resolved input outpointstxid and vout for each resolved input
Resolved input amounts and assetsamount_sat and asset for each resolved input
instance.NAMEContract-template fields, plus any values set by hooks that have already run
params.NAMERuntime values supplied by the user or computed for the action

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.

Reporting failures

A manifest carries no error-code table and no author-supplied failure messages, so there is nothing to look up: report the failure you actually hit. The four a user will meet are a parse error naming the offending key, an input that could not be resolved, a witness that does not match the program, and a covenant the dry-run could not satisfy.

Name the input or field involved in each. A wallet that collapses these into one message makes the difference between "you gave me a bad value" and "this manifest is wrong" invisible, and only the first is something the user can act on.


Notes on provided_inputs

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

  • Treat every entry in provided_inputs.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: treat every value in it as hostile until checked against the chain.

Glossary

Action — A single transaction recipe in a manifest: its inputs, outputs, and the witnesses each input supplies. An action inside a contract template is identical to a top-level one, except that its formulas may also reference instance.*.

allow_change — An action's bound on undeclared change: "none" (the default), "lbtc_only", or "any". Every output a transaction carries must be written in the manifest; the network fee is the one exception.

Blinding factor — The 32-byte scalar hiding an asset or an amount in a confidential output. An output's blinding block pins what the builder would otherwise pick at random — needed by any covenant that verifies its own UTXOs as Pedersen commitments.

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

Compute spec — How a param's value is produced instead of being prompted for: a bare formula string, or a structured expr, tapleaf, script_hash, wallet, simf_fn or hook.

Contract template — A typed contract definition with named fields and actions, under the top-level contract_templates map. Each deployment of a template is one instance, with its own field values.

Constructor — An action carrying a create_instance block, which creates a new instance when it runs. There is no separate flag: the block is what makes the action a constructor, and it is only legal inside a contract template.

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

debug_symbols — The simplicity_hl flag deciding whether covenants compile with debug information. It changes every program's CMR, and therefore every address derived from it, so two tools that disagree about it see different contracts.

Field — A contract template's compile-time value, baked into a covenant script at deploy time and stored per deployment as part of the instance. Changing one changes the script's address. Referenced as instance.NAME.

Instance — The field values of one deployment of a contract template. A wallet must keep them: without them it cannot rebuild the contract's addresses.

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

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

Param — An action's runtime value: prompted for, computed, or set by a hook. It affects only the transaction being built, never an address. Referenced as params.NAME. params is the only runtime value namespace.

provided_inputs — UTXOs pre-filled alongside an instance, letting a wallet spend a counterparty's output it never indexed.

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

State — The live on-chain UTXO set for one instance, updated after every broadcast. How a wallet stores it is its own business; the format only requires that it survives between actions.

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

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

Witness — A value supplied to satisfy a Simplicity program when spending: a signature, a path selector, a leaf selector, or a computed value. Witnesses are declared on the input whose script they satisfy.

$comment / $schema — Authoring keys, legal on any object at any depth and carrying no protocol meaning. A tool strips both before interpreting a manifest.