Worked example: a Last Will covenant
Problem. Lock funds so that the owner can move them with a hot key, escape the arrangement with a cold key, and — if the owner goes silent for 180 days — let an heir claim them. All three rules enforced on-chain.
This recipe puts the last few lessons to work on a real, non-trivial contract: the Last Will, adapted from the SimplicityHL examples. It has three spending paths, a relative timelock, and a recursive covenant — and it's a chance to see a multi-path witness selector in a complete file.
The three paths:
| Path | Who | When | Effect |
|---|---|---|---|
| Refresh | owner's hot key | any time | moves the funds but repeats the covenant |
| ColdBreak | owner's cold key | any time | spends out, ending the covenant |
| Inherit | heir's key | after 180 days of no movement | spends out to the heir |
The cold key is the escape hatch; the hot key is for everyday moves and is forced to re-lock; the inheritor is the dead-man's switch.
The program
The contract lives in
last_will.simf.
Two adaptations from the upstream example make it work with tx-manifest-wallet:
- The keys and the timelock are compile parameters (
param::INHERITOR_PUB_KEY,param::HOT_PUB_KEY,param::COLD_PUB_KEY, andparam::INHERIT_BLOCKS) instead of hardcoded constants, so the manifest can wire them — exactly likePUB_KEYin Hello World. - The path is chosen by a dedicated
SPEND_PATHwitness, and each signature is its own witness. The upstream version nested the signatures inside the selector; tx-manifest-wallet computes signatures as standaloneSignaturewitnesses, so we split them out (the idiom from Witnesses).
fn main() { match witness::SPEND_PATH { Left(inherit: ()) => inherit_spend(witness::INHERITOR_SIG), Right(cold_or_hot: Either<(), ()>) => match cold_or_hot { Left(cold: ()) => cold_spend(witness::COLD_SIG), Right(hot: ()) => refresh_spend(witness::HOT_SIG), }, } }
SPEND_PATH has type Either<(), Either<(), ()>>, so the three branches are
selected by Left(()), Right(Left(())), and Right(Right(())). Whichever
branch you take reads exactly one signature witness; the other two live on pruned
branches and are auto-zeroed.
The two interesting helpers:
#![allow(unused)] fn main() { fn inherit_spend(inheritor_sig: Signature) { let blocks: Distance = param::INHERIT_BLOCKS; // configurable timelock (a compile param) jet::check_lock_distance(blocks); checksig(param::INHERITOR_PUB_KEY, inheritor_sig); } fn recursive_covenant() { assert!(jet::eq_32(jet::num_outputs(), 2)); // exactly 2 outputs let this_script_hash: u256 = jet::current_script_hash(); let output_script_hash: u256 = unwrap(jet::output_script_hash(0)); assert!(jet::eq_256(this_script_hash, output_script_hash)); // output 0 = same covenant assert!(unwrap(jet::output_is_fee(1))); // output 1 = fee } }
inherit_spend enforces a relative timelock — the heir's spend is only valid
once the UTXO is INHERIT_BLOCKS blocks old (a compile param; ~180 days ≈ 25,920
one-minute Liquid blocks). recursive_covenant (used by the hot-key refresh)
forces the spend to recreate the same covenant in output 0 and have the explicit
fee in output 1, with nothing else.
The manifest
A will is something you deploy once and then operate — exactly what a
class models. We define a
last_will_contract class whose fields are the three keys, and whose
methods are the four actions. A constructor method (Fund) records the
keys in an instance file the first time you set the will up; the spend methods
read them back.
"classes": {
"last_will_contract": {
"fields": {
"INHERITOR_PUB_KEY": { "type": "pubkey" },
"HOT_PUB_KEY": { "type": "pubkey" },
"COLD_PUB_KEY": { "type": "pubkey" },
"INHERIT_BLOCKS": { "type": "u16" }
},
"methods": { "Fund": { ... }, "ColdBreak": { ... }, "Refresh": { ... }, "Inherit": { ... } }
}
}
The last_will UTXO type (top-level, as before) wires those three fields into the
program — the field names double as the compile params the script consumes:
"utxo_types": {
"last_will": {
"script": {
"type": "simplicity",
"source": "./last_will.simf",
"compile_params": {
"INHERITOR_PUB_KEY": "INHERITOR_PUB_KEY",
"HOT_PUB_KEY": "HOT_PUB_KEY",
"COLD_PUB_KEY": "COLD_PUB_KEY",
"INHERIT_BLOCKS": "INHERIT_BLOCKS"
}
},
"asset": "lbtc"
}
}
The constructor: Fund
Fund does double duty — it locks the funds and writes the instance file. It
takes the three keys as params (two auto-filled from your wallet) and an amount,
locks a wallet UTXO into the covenant, then create_instance records the keys:
"Fund": {
"is_constructor": true,
"params": {
"INHERITOR_PUB_KEY": {
"type": "pubkey",
"description": "The heir's x-only public key. They can claim the funds 180 days after the last move."
},
"HOT_PUB_KEY": {
"type": "pubkey",
"description": "Owner's hot key. Auto-filled from your wallet signing key."
},
"COLD_PUB_KEY": {
"type": "pubkey",
"description": "Owner's cold key. Your wallet's oracle key — the covenant escape hatch."
},
"INHERIT_BLOCKS": {
"type": "u16",
"default": "25920",
"description": "Blocks of inactivity before the heir may claim. ~180 days ≈ 25920 (1-minute Liquid blocks). Max 65535."
},
"amount_sat": {
"type": "u64",
"description": "Amount in satoshis to place under the will."
}
},
"inputs": [
{
"id": "funding_input",
"description": "Wallet UTXO providing the funds.",
"utxo_source": "wallet",
"asset": "lbtc",
"amount_sat": {
"min_amount": "params.amount_sat"
}
}
],
"outputs": [
{
"id": "will_out",
"description": "The funded last-will output.",
"destination": {
"utxo_type": "last_will"
},
"amount_sat": "params.amount_sat",
"asset": "lbtc"
},
{
"id": "change_out",
"description": "Change returned to the funding wallet.",
"destination": "change",
"asset": "lbtc",
"optional": true
}
],
"create_instance": {
"class": "last_will_contract",
"fields": {
"INHERITOR_PUB_KEY": "$params.INHERITOR_PUB_KEY",
"HOT_PUB_KEY": "$params.HOT_PUB_KEY",
"COLD_PUB_KEY": "$params.COLD_PUB_KEY",
"INHERIT_BLOCKS": "$params.INHERIT_BLOCKS"
}
}
}
HOT_PUB_KEY auto-fills from your wallet signing key; COLD_PUB_KEY is your
wallet's oracle key (take it from info — see Setup);
the heir gives you INHERITOR_PUB_KEY. Each param value is written into the
compile params, so will_out's covenant address is computed from the keys you
just supplied — before the instance exists. After broadcast, create_instance
writes those same three keys into txmanifest.instance.json.
One instance per will. Unlike Hello World — which had no
classesand so no instance file — the keys here are a class's fields, persisted at construction. Every later spend reads them from the instance, so you only enter the keys once. This is the full class / instance model from Instance, state & constructors.
Each spend reads the instance
Every spend is a method whose input is the last_will UTXO (found in the state
file) with a SPEND_PATH selector and the matching Signature. The signature
keys reference compile_params.* — the fields loaded back from the instance file,
so you never re-enter them. ColdBreak:
"ColdBreak": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
"COLD_SIG": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
}
}
},
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
```json
"ColdBreak": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Left(()))" },
"COLD_SIG": {
"type": "Signature",
"sig_type": "sig_hash_all",
"source": { "type": "wallet", "key": "compile_params.COLD_PUB_KEY" }
}
}
},
{ "id": "fee_input", "utxo_source": "wallet", "asset": "lbtc", "optional": true }
],
"outputs": [
{ "id": "to_wallet", "destination": "wallet", "asset": "lbtc", "amount_sat": "will_in.amount_sat" },
{ "id": "fee_change", "destination": "change", "asset": "lbtc", "optional": true }
]
}
This is exactly the Hello World spend plus a
SPEND_PATH selector. Inherit is identical but with SPEND_PATH = Left(())
and INHERITOR_SIG.
Refresh is the one that's different — the covenant forces it to re-lock:
"Refresh": {
"inputs": [
{
"id": "will_in",
"utxo_source": { "utxo_type": "last_will" },
"witnesses": {
"SPEND_PATH": { "type": "simplicityhl", "value": "Right(Right(()))" },
"HOT_SIG": { "type": "Signature", "sig_type": "sig_hash_all", "source": { "type": "wallet", "key": "compile_params.HOT_PUB_KEY" } }
}
}
],
"outputs": [
{
"id": "will_again",
"destination": { "utxo_type": "last_will" },
"asset": "lbtc",
"amount_sat": "will_in.amount_sat - fee",
"required_index": 0
}
]
}
Three things the covenant dictates here:
required_index: 0—recursive_covenantchecksoutput_script_hash(0), so the re-locked output must be output 0.- No change output. The program asserts exactly two outputs (covenant + fee).
Because this action declares no
"change"output, the builder never adds one — it folds the L-BTC surplus into the fee. There's no separate fee input either: a recursive covenant can't add a wallet change output to return the leftover, so the will pays its own fee and shrinks by it each refresh. amount_sat: "will_in.amount_sat - fee"— thefeekeyword. The will is re-locked at its current value minus the network fee. The fee output (output- then ends up being exactly
fee.
- then ends up being exactly
The
feekeyword.feeis a reserved formula word for the estimated network fee. It evaluates to0while the outputs are first assembled, then the tool estimates the fee from the transaction's size and re-evaluates any amount that usedfee— sowill_in.amount_sat - feelands on the right value before signing. Nofee_satparam to guess at.
How the builder decides on change. A change output is added only when an action lists a
"destination": "change"output. Methods that omit it — likeRefresh— get exactly their declared outputs plus the fee, which is what a recursive covenant needs.
Skip the prompts: a params file
Fund needs three pubkeys. Typing them in is error-prone, so the CLI can read
them from a params file: a flat JSON object of param → value that pre-fills
(or fully supplies) the prompts.
You don't even pass a flag. The tool auto-discovers a file named
<stem>.<network>.json next to the manifest (the <stem> is the manifest's
filename stem) — so for testnet it loads txmanifest.testnet.json from
examples/last_will/:
{
"INHERITOR_PUB_KEY": "…heir's wallet pubkey…",
"HOT_PUB_KEY": "…owner's wallet pubkey…",
"COLD_PUB_KEY": "…owner's oracle pubkey…",
"INHERIT_BLOCKS": "25920",
"amount_sat": "100000"
}
(An explicit --params <file> works too, and overrides the auto-discovered one.)
In this example the heir is a second wallet, so two of the keys come from one
wallet and one from another. Rather than copy three pubkeys out of info by hand,
the book ships scripts that do it for you:
.\create_wallet.ps1 # owner wallet -> wallet.json
.\create_inherit_wallet.ps1 # heir wallet -> wallet-inherit.json
.\make_params.ps1 # reads both, writes examples/last_will/txmanifest.testnet.json
make_params.ps1 runs info on each wallet, pulls out the signing and oracle
pubkeys, and writes the params file — INHERITOR_PUB_KEY from the heir wallet,
HOT_PUB_KEY / COLD_PUB_KEY from the owner wallet. (HOT_PUB_KEY also
auto-fills from the wallet at run time, since it's a wallet_key source; the file
just makes every value explicit.)
Run it
Run everything from the repository root, where the scripts put the wallets and
params file. With the params file in place, construct the will — Fund reads
every value from the file, so there's nothing to type. It locks the funds and
writes examples/last_will/txmanifest.instance.json:
txw run examples/last_will/txmanifest.json Fund \
--network testnet --wallet wallet.json
Then the cold-key break-out is the most straightforward spend to run, since your oracle key signs it — and you don't re-enter any keys, because they're read from the instance:
txw run examples/last_will/txmanifest.json ColdBreak \
--network testnet --wallet wallet.json
ColdBreak finds the last_will UTXO in the state file, rebuilds the covenant
address from the instance's three fields, signs with the cold (oracle) key,
dry-runs the program down the Right(Left(())) branch, and broadcasts.
Caveats on the other two paths.
RefreshandInheritexercise covenant features the reference tool doesn't fully drive yet:
Inheritneeds the input's relative timelock (nSequence) set to ≥180 days forcheck_lock_distanceto pass, and is signed by the heir's key — not your wallet. It's the dead-man's-switch path; treat it as illustrative until relative-locktime support lands.Refreshrelies on the explicit-fee / no-change output layout with thefeekeyword. The estimate adds a fixed allowance for the covenant's Simplicity witness (whose exact size is only known after signing), so it errs slightly high to stay above the relay minimum — the extra just goes to the fee. Fund the will with a little headroom so each refresh's fee fits.
Try next
You've now seen multiple spending paths, a timelock, and a recursive covenant in one file. The recipes that go deeper on those building blocks: Covenant UTXO types and Multiple spending paths.