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

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