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:
- The contract's state locates the UTXO. We never type a txid — the wallet
already knows the live
p2pk_outputentry, becausePayrecorded it. - 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.simfwith that same key and confirm the address matches. - A witness satisfies the program.
p2pk.simfdemands a BIP340 signature over the transaction.Receiveprovides one.
Prerequisites. You must have run
Payfirst, so the contract's state holds ap2pk_output. Crucially, in Part 1 you must have locked the funds to one of your own wallet's keys (e.g. the key frominfo) — because spending now requires signing with that key's private half. If you paid to someone else's pubkey, only they can runReceive.
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'ssig_all_hash, a commitment over the whole transaction. (This is not the classic Bitcoin/ElementsSIGHASH_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 theSIGNATUREwitness.
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-jetsto watchbip_0340_verifyandsig_all_hashexecute during the dry-run, or--export-pset out.jsonto 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.