From 1e69c5b2d57eed2fb42b0e92ea701c68604aef3d Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 26 Sep 2026 19:59:56 -0700 Subject: [PATCH 1/6] Add last_will.simf example in examples This uses the old `Either` style instead of the experimental `enum` version, because `enum` currently doesn't work under Simplex. The tests add some detailed information about what they're trying to accomplish and what happened, visible when they're run with `simplex test -v` In addition to learning more about Simplex, new users will understand a little more about Simplicity covenants. --- Cargo.toml | 2 +- README.md | 2 +- examples/last_will/.gitignore | 1 + examples/last_will/Cargo.toml | 11 + examples/last_will/README.md | 62 ++ examples/last_will/Simplex.toml | 4 + examples/last_will/simf/last_will.simf | 64 ++ examples/last_will/simf/timelocks.simf | 17 + examples/last_will/src/lib.rs | 1 + examples/last_will/tests/last_will_test.rs | 730 +++++++++++++++++++++ 10 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 examples/last_will/.gitignore create mode 100644 examples/last_will/Cargo.toml create mode 100644 examples/last_will/README.md create mode 100644 examples/last_will/Simplex.toml create mode 100644 examples/last_will/simf/last_will.simf create mode 100644 examples/last_will/simf/timelocks.simf create mode 100644 examples/last_will/src/lib.rs create mode 100644 examples/last_will/tests/last_will_test.rs diff --git a/Cargo.toml b/Cargo.toml index b07e83f1..ff670c8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "3" members = ["crates/*"] -exclude = ["examples/basic", "fixtures"] +exclude = ["examples/basic", "examples/last_will", "fixtures"] [workspace.package] license = "MIT" diff --git a/README.md b/README.md index f95a4418..0a335f1c 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ To view the available options, run the help command: simplex -h ``` -### Example +### Examples Check out the complete project examples in the `examples` directory to learn more. diff --git a/examples/last_will/.gitignore b/examples/last_will/.gitignore new file mode 100644 index 00000000..327555cd --- /dev/null +++ b/examples/last_will/.gitignore @@ -0,0 +1 @@ +src/artifacts diff --git a/examples/last_will/Cargo.toml b/examples/last_will/Cargo.toml new file mode 100644 index 00000000..57371240 --- /dev/null +++ b/examples/last_will/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "last_will_example" +edition = "2024" +rust-version = "1.91.0" +version = "0.1.0" + +[dependencies] +smplx-std = { path = "../../crates/simplex" } + +anyhow = { version = "1.0.101" } +bitcoin_hashes = { version = "0.14" } diff --git a/examples/last_will/README.md b/examples/last_will/README.md new file mode 100644 index 00000000..ca6feb15 --- /dev/null +++ b/examples/last_will/README.md @@ -0,0 +1,62 @@ +# Last Will Simplex Example + +This example builds on [`examples/basic`](../basic) to show something +Bitcoin Script can't express at all: a *recursive covenant*. Where +`p2pk.simf` is a single spending condition, `last_will.simf` re-creates +itself on-chain across a whole chain of transactions, and offers three +different actions depending on which key is used. + +## Overview + +`last_will.simf` implements a simple inheritance scheme with three +hard-coded keys: + +* **Inherit** -- after the covenant sits untouched for 1440 blocks + (relative timelock), the inheritor's key can withdraw the funds. +* **ColdSpend** -- the owner's cold key can break out of the covenant at + any time, with no wait required. +* **HotSpend** -- the owner's hot key can "check in" at any time, + resetting the inheritance clock. `refresh_spend` doesn't just check + a signature: it calls `recursive_covenant()`, which requires the + transaction to pay back into this exact same covenant script (output 0) + plus a real Elements fee output (output 1). This self-referential output + check, with the covenant examining its own outputs to constrain them, is + the mechanism with no Bitcoin Script equivalent. + +For Simplex compatibility, these actions are represented here as Either +types (instantiated with Left/Right) rather than named `enum` values. + +The repository structure is the same shape as `examples/basic`: + +```md +root +├── simf — Simplicity smart contracts source directory +├── src — The project's Rust code (witness and transaction builders) +│ └── artifacts — Simplicity artifacts generated by Simplex (gitignored) +├── tests — Simplex tests directory +├── Cargo.toml — The project's Rust configuration +└── Simplex.toml — The project's Simplex configuration +``` + +### Build + +```bash +simplex build +``` + +As with `examples/basic`, this generates `src/artifacts`, already declared as a module in +`src/lib.rs`. + +### Test + +```bash +simplex test last_will_test -v +``` + +The `-v` option adds verbose output. The test suite includes narration, with each test +showing what it's attempting and how the network responded. Several tests expect a +rejection (wrong key, broken `recursive_covenant()` check, or inheritance claimed too +early). + +One test, `inherit_after_wait_succeeds`, synthetically mines 1440 blocks to make the +inheritance wait real. This should be relatively rapid with local `elementsd`. diff --git a/examples/last_will/Simplex.toml b/examples/last_will/Simplex.toml new file mode 100644 index 00000000..5f23542e --- /dev/null +++ b/examples/last_will/Simplex.toml @@ -0,0 +1,4 @@ +[build] +src_dir = "./simf" +simf_files = ["*.simf"] +out_dir = "./src/artifacts" diff --git a/examples/last_will/simf/last_will.simf b/examples/last_will/simf/last_will.simf new file mode 100644 index 00000000..ceead80e --- /dev/null +++ b/examples/last_will/simf/last_will.simf @@ -0,0 +1,64 @@ +/* + * LAST WILL + * + * The inheritor can spend the coins if the owner doesn't move them for + * 1440 blocks. The owner has to repeat the covenant when he moves the + * coins with his hot key. The owner can break out of the covenant with + * his cold key. + * + * Adapted from `last_will.simf` in BlockstreamResearch/SimplicityHL. + * Changes: + * + * - The older Left/Right mechanism for selecting the spending action, + * instead of an `enum`, is restored because Simplex currently does + * not support contracts using `enum`. + * - The inheritance distance is reduced to 1440 blocks (1 day on Liquid) + * to reduce CPU requirements for mining these blocks rapidly during the + * demo. + */ +use crate::timelocks::enforce_relative_distance; + +fn checksig(pk: Pubkey, sig: Signature) { + let msg: u256 = jet::sig_all_hash(); + jet::bip_0340_verify((pk, msg), sig); +} + +// Enforce the covenant to repeat in the first output. +// +// Elements has explicit fee outputs, so enforce a fee output in the second output. +// Disallow further outputs. +fn recursive_covenant() { + assert!(jet::eq_32(jet::num_outputs(), 2)); + 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)); + assert!(unwrap(jet::output_is_fee(1))); +} + +fn inherit_spend(inheritor_sig: Signature) { + let blocks_1440: Distance = 1440; + enforce_relative_distance(blocks_1440); + let inheritor_pk: Pubkey = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798; // 1 * G + checksig(inheritor_pk, inheritor_sig); +} + +fn cold_spend(cold_sig: Signature) { + let cold_pk: Pubkey = 0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5; // 2 * G + checksig(cold_pk, cold_sig); +} + +fn refresh_spend(hot_sig: Signature) { + let hot_pk: Pubkey = 0xf9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9; // 3 * G + checksig(hot_pk, hot_sig); + recursive_covenant(); +} + +fn main() { + match witness::INHERIT_OR_NOT { + Left(inheritor_sig: Signature) => inherit_spend(inheritor_sig), + Right(cold_or_hot: Either) => match cold_or_hot { + Left(cold_sig: Signature) => cold_spend(cold_sig), + Right(hot_sig: Signature) => refresh_spend(hot_sig), + }, + } +} diff --git a/examples/last_will/simf/timelocks.simf b/examples/last_will/simf/timelocks.simf new file mode 100644 index 00000000..3ac178ad --- /dev/null +++ b/examples/last_will/simf/timelocks.simf @@ -0,0 +1,17 @@ +pub fn enforce_relative_distance(min_distance: Distance) { + // Assert that the current input is spent in a transaction that can + // only appear a distance of at least min_distance blocks after the + // block containing the input UTXO. + // Panic otherwise. + + // This is a replacement for the deprecated jet::check_lock_distance. + + // Transaction version must be at least 2. + assert!(jet::le_32(2, jet::version())); + + // Fetch and parse sequence + let actual_data: Either = unwrap(jet::parse_sequence(jet::current_sequence())); + let actual_distance: Distance = unwrap_left::(actual_data); + + assert!(jet::le_16(min_distance, actual_distance)); +} diff --git a/examples/last_will/src/lib.rs b/examples/last_will/src/lib.rs new file mode 100644 index 00000000..91946c71 --- /dev/null +++ b/examples/last_will/src/lib.rs @@ -0,0 +1 @@ +pub mod artifacts; diff --git a/examples/last_will/tests/last_will_test.rs b/examples/last_will/tests/last_will_test.rs new file mode 100644 index 00000000..e2860ca8 --- /dev/null +++ b/examples/last_will/tests/last_will_test.rs @@ -0,0 +1,730 @@ +// Narrated Simplex demo for `last_will.simf`, a recursive covenant. +// +// These tests exercise various failure conditions in addition to the +// happy paths, showing how the contract logic and network consensus +// enforce spending rules. + +use last_will_example::artifacts::last_will::LastWillProgram; +use last_will_example::artifacts::last_will::derived_last_will::{LastWillArguments, LastWillWitness}; + +use simplex::program::{ProgramTrait, WitnessTrait}; +use simplex::simplicityhl::elements::pset::PartiallySignedTransaction; +use simplex::simplicityhl::elements::{Script, Sequence}; +use bitcoin_hashes::Hash as _; +use simplex::simplicityhl::simplicity::bitcoin::secp256k1::{self, Keypair, Message, SecretKey}; +use simplex::either::Either; +use simplex::simplicityhl::WitnessValues; +use simplex::provider::SimplicityNetwork; +use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature}; + +// The contract hardcodes its own public keys in source (`1*G`, `2*G`, +// `3*G`, for inherit/cold/hot, respectively) rather than taking them as +// `param::` arguments. The matching private keys are these same small +// integers. These hard-coded keys are just for demonstration purposes, +// while real deployments would use `param::` values for the real public +// keys. +fn scalar(n: u8) -> [u8; 32] { + let mut bytes = [0u8; 32]; + bytes[31] = n; + bytes +} +const INHERITOR_KEY: u8 = 1; +const COLD_KEY: u8 = 2; +const HOT_KEY: u8 = 3; + +// The original contract uses 25920 blocks, about 18 days at Liquid's +// 1-minute block time. This demo uses the smaller value 1440 (1 day) +// instead, purely for resource purposes. Mining tens of thousands of +// blocks on regtest may risk a `nextest` timeout. +const INHERITANCE_DISTANCE: u16 = 1440; + +#[derive(Clone)] +struct SpendPathWitness { + variant: &'static str, + signature: [u8; 64], +} + +impl SpendPathWitness { + fn placeholder(_program: &LastWillProgram, variant: &'static str) -> anyhow::Result { + Ok(Self { + variant, + signature: [0u8; 64], + }) + } + + fn with_signature(&self, signature: [u8; 64]) -> Self { + Self { + variant: self.variant, + signature, + } + } +} + +impl WitnessTrait for SpendPathWitness { + fn build_witness(&self) -> WitnessValues { + // `witness::INHERIT_OR_NOT` is an `Either>`, so each named spending path + // is a branch of that type. Simplex's codegen generates a + // real `LastWillWitness` for this shape, so all we have to do + // is name the branch; the conversion into Simplicity values + // is generated. Using the experimental `enum` feature would + // produce more readable SimplicityHL code, but is currently not + // supported in Simplex. + let inherit_or_not = match self.variant { + "Inherit" => Either::Left(self.signature), + "ColdSpend" => Either::Right(Either::Left(self.signature)), + "HotSpend" => Either::Right(Either::Right(self.signature)), + other => panic!("unknown spending path: {other}"), + }; + + LastWillWitness { inherit_or_not }.build_witness() + } +} + +fn program() -> LastWillProgram { + LastWillProgram::new(&LastWillArguments {}) +} + +fn fee_output(amount: u64, network: &SimplicityNetwork) -> PartialOutput { + // Unlike Bitcoin, Elements/Liquid represents the transaction + // fee as an explicit output with an empty scriptPubKey. + // `recursive_covenant()` specifically checks for this at output + // index 1, using `jet::output_is_fee(1)`. + PartialOutput::new(Script::new(), amount, network.policy_asset()) +} + +/// The destination doesn't matter for demo purposes, so it just goes +/// back to the test wallet's own address. +fn remainder_output(context: &simplex::TestContext, amount: u64) -> PartialOutput { + PartialOutput::new( + context.get_default_signer().get_address().script_pubkey(), + amount, + context.get_network().policy_asset(), + ) +} + +/// `mine_until_height` issues a single RPC call asking the node to +/// generate however many blocks are needed in one shot. Asking for too +/// many blocks in one call can cause timeout problems, so we mine in +/// smaller batches here. +fn mine_up_to(context: &simplex::TestContext, target: u64) -> anyhow::Result<()> { + const BATCH: u64 = 500; + loop { + let current = u64::from(context.get_default_provider().fetch_tip_height()?); + if current >= target { + return Ok(()); + } + let next = (current + BATCH).min(target); + context.get_network_utils().mine_until_height(next)?; + println!(" ...mined to height {next} ({} to go)", target - next); + } +} + +/// Signs `pst`'s input at `input_index` with a BIP-340 signature +/// over `jet::sig_all_hash()`, using the given raw 32-byte private +/// key scalar directly. Here, the possible private keys are +/// hard-coded. +fn sign_with_key( + pst: &PartiallySignedTransaction, + program: &LastWillProgram, + input_index: usize, + network: &SimplicityNetwork, + raw_privkey: [u8; 32], +) -> [u8; 64] { + let env = program + .as_ref() + .get_env(pst, input_index, network) + .expect("env should build"); + let msg = Message::from_digest(env.c_tx_env().sighash_all().to_byte_array()); + + let secp = secp256k1::Secp256k1::new(); + let secret_key = SecretKey::from_slice(&raw_privkey).expect("valid scalar"); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + + secp.sign_schnorr(&msg, &keypair).serialize() +} + +/// Funds a fresh covenant instance, builds a `FinalTransaction` +/// spending it with the given `variant`/`sequence`/`outputs`, signs it +/// with `signing_key`, finalizes locally, and broadcasts. Returns the +/// local-execution or broadcast error (if any) unexamined, so callers +/// can assert either success or a specific failure result. +/// +/// `description` is printed immediately before the actual submission, +/// and (only on success) immediately after. The `nextest` wrapper will +/// capture the output and print it only when the test completes. +fn spend_action( + context: &simplex::TestContext, + description: &str, + variant: &'static str, + sequence: Sequence, + signing_key: [u8; 32], + outputs: Vec, + utxo_amount: u64, +) -> anyhow::Result { + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + + context.get_default_signer().send(script.clone(), utxo_amount)?; + + let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let placeholder = SpendPathWitness::placeholder(&prog, variant)?; + + let mut ft = FinalTransaction::new(); + ft.add_program_input( + PartialInput::new(utxos[0].clone()).with_sequence(sequence), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), + RequiredSignature::None, + ); + for output in outputs { + ft.add_output(output); + } + + let (mut pst, _secrets) = ft.extract_pst(); + + let sig = sign_with_key(&pst, &prog, 0, context.get_network(), signing_key); + let real_witness = placeholder.with_signature(sig); + + println!("Submitting {description}..."); + + // Local Simplicity execution: this is where an + // `AssertFailed`/`PrunedBranch` case would surface, before the + // transaction ever reaches the network. A failure here (or at + // broadcast, below) propagates via `?` without printing a "-> + // rejected" line itself -- that's the caller's job, since callers + // differ on how they want to describe an expected rejection (a + // specific error-message check, a generic assertion-failure check, + // and so on). + let pruned_witness = prog + .as_ref() + .finalize(&pst, &real_witness.build_witness(), 0, context.get_network())?; + pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + + let tx = pst.extract_tx()?; + let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + println!(" -> accepted: {receipt}"); + Ok(receipt.to_string()) +} + +fn seq_distance(blocks: u16) -> Sequence { + Sequence::from_consensus(u32::from(blocks)) +} + +/// Asserts that `spend_action`'s result failed during LOCAL Simplicity +/// execution (`AssertFailed`) rather than succeeding or failing only +/// once it reached a node. Every test that expects a spend to be +/// rejected outright (wrong key, or a `recursive_covenant()` violation) +/// fails this way: the contract's own logic catches it before broadcast +/// is ever attempted. +fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> anyhow::Result<()> { + match result { + Ok(receipt) => anyhow::bail!("expected local execution to reject this ({what_was_wrong}), but it broadcast: {receipt}"), + Err(err) => { + let msg = err.to_string(); + anyhow::ensure!( + msg.contains("Jet failed during execution"), + "expected an assertion failure ({what_was_wrong}), got a different error: {msg}" + ); + println!(" -> rejected locally, as expected: {what_was_wrong}\n"); + Ok(()) + } + } +} + +/// A simple non-covenant script (OP_TRUE), as an example of a script +/// that definitely *is not* equal to this covenant (to demonstrate +/// the covenant's ability to distinguish copies of itself from other +/// scripts). +fn unrelated_script() -> Script { + Script::from(vec![0x51]) +} + +#[simplex::test] +fn cold_spend_breaks_out_of_the_covenant(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== ColdSpend: the owner cancels the covenant entirely ===\n\n\ + `cold_spend` is the simplest of the three paths -- just a signature check\n\ + against the hardcoded cold key (2*G). Whoever holds the cold private key\n\ + can withdraw the funds from this covenant at any time, ending it.\n" + ); + + spend_action( + &context, + "ColdSpend transaction, signed with the cold key", + "ColdSpend", + Sequence::default(), + scalar(COLD_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + )?; + Ok(()) +} + +#[simplex::test] +fn hot_spend_refreshes_the_covenant(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend: the owner checks in, and the covenant is refreshed ===\n\n\ + This is the core covenant example: `refresh_spend` checks a signature but also\n\ + calls `recursive_covenant()`, which requires the transaction to have exactly\n\ + two outputs: output 0 must be a NEW UTXO paying back to this exact same\n\ + covenant script (verified by comparing commitment hashes, not by trusting the\n\ + witness), and output 1 must be a fee output. This is how a Simplicity covenant\n\ + 'lives on' across transactions, which Bitcoin Script can't express because it\n\ + has no way to inspect or constrain transaction outputs.\n" + ); + + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + + spend_action( + &context, + "HotSpend transaction, signed with the hot key", + "HotSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![ + PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + fee_output(1_000, context.get_network()), + ], + 100_000, + )?; + + println!( + "The covenant's {INHERITANCE_DISTANCE}-block inheritance clock has effectively\n\ + been reset -- the new UTXO this transaction creates is a fresh instance of the\n\ + same covenant, with the same overall rules as the original.\n" + ); + Ok(()) +} + +#[simplex::test] +fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== Inherit, attempted immediately: rejected by the network ===\n\n\ + The inheritor declares a sequence number claiming the full {INHERITANCE_DISTANCE}-block\n\ + wait has passed. `enforce_relative_distance` only checks that the DECLARED\n\ + value is large enough -- it can't see the real chain state, so local execution\n\ + succeeds. But we deliberately don't mine any blocks first, so the real distance\n\ + elapsed since funding falls short of the declared value. The node itself then\n\ + catches this and rejects the transaction as too early.\n" + ); + + let result = spend_action( + &context, + &format!( + "Inherit transaction, declaring the full {INHERITANCE_DISTANCE}-block wait, with no blocks actually mined" + ), + "Inherit", + seq_distance(INHERITANCE_DISTANCE), + scalar(INHERITOR_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + + match result { + Ok(receipt) => { + anyhow::bail!("expected the node to reject this broadcast, but it succeeded: {receipt}") + } + Err(err) => { + let msg = err.to_string(); + anyhow::ensure!( + msg.contains("non-BIP68-final"), + "expected a `non-BIP68-final` rejection specifically, got: {msg}" + ); + println!(" -> rejected by the node: {msg}\n"); + println!( + "The `non-BIP68-final` message is triggered by the node's mempool policy,\n\ + showing how the timelock guarantee is enforced by the network.\n" + ); + } + } + Ok(()) +} + +#[simplex::test] +fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== Inherit successfully: fast-forwarding {INHERITANCE_DISTANCE} blocks ===\n\n\ + At Liquid's 1-minute block time, {INHERITANCE_DISTANCE} blocks is about a day,\n\ + against the 25920 blocks (~18 days) the upstream contract asks for. In a\n\ + real deployment, the inheritor has to wait the correct amount of time\n\ + before inheriting. For demonstration purposes, we can fast-forward the\n\ + network by asking the regtest to mine the required number of blocks\n\ + immediately, ignoring its default blocktime delay.\n" + ); + + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + context.get_default_signer().send(script.clone(), 100_000)?; + + let target = context.get_default_provider().fetch_tip_height()? as u64 + u64::from(INHERITANCE_DISTANCE); + mine_up_to(&context, target)?; + println!("Mined up to height {target}.\n"); + + let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let placeholder = SpendPathWitness::placeholder(&prog, "Inherit")?; + + let mut ft = FinalTransaction::new(); + ft.add_program_input( + PartialInput::new(utxos[0].clone()).with_sequence(seq_distance(INHERITANCE_DISTANCE)), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), + RequiredSignature::None, + ); + ft.add_output(remainder_output(&context, 99_000)); + ft.add_output(fee_output(1_000, context.get_network())); + + let (mut pst, _secrets) = ft.extract_pst(); + let sig = sign_with_key(&pst, &prog, 0, context.get_network(), scalar(INHERITOR_KEY)); + let real_witness = placeholder.with_signature(sig); + + let pruned_witness = prog + .as_ref() + .finalize(&pst, &real_witness.build_witness(), 0, context.get_network())?; + pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + + println!("Submitting the same Inherit transaction as before, now that the wait is real..."); + let tx = pst.extract_tx()?; + let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + println!(" -> accepted: {receipt}"); + + println!( + "\nThe same spend that was rejected before now succeeds. The contract and\n\ + witness are the same, but the blockchain state has changed.\n" + ); + Ok(()) +} + +// ===================================================================== +// Wrong-key tests: each of the three paths checks a signature against a +// specific hardcoded public key. These tests deliberately mix up these +// keys and so fail during local execution. +// ===================================================================== + +#[simplex::test] +fn hot_spend_with_cold_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend, signed with the cold key instead of the hot key ===\n\n\ + Correctly structured HotSpend, but signed with the cold private key (2)\n\ + instead of the expected hot key (3). The signature will be rejected.\n" + ); + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + let result = spend_action( + &context, + "HotSpend transaction, signed with the cold key instead of the hot key", + "HotSpend", + Sequence::default(), + scalar(COLD_KEY), + vec![ + PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "cold key signed a HotSpend") +} + +#[simplex::test] +fn hot_spend_with_inheritor_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend, signed with the inheritor's key instead of the hot key ===\n\n\ + Same idea as the cold-key case, with the third key instead: the inheritor\n\ + cannot use their key to act as the owner and bypass the timelock.\n" + ); + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + let result = spend_action( + &context, + "HotSpend transaction, signed with the inheritor's key instead of the hot key", + "HotSpend", + Sequence::default(), + scalar(INHERITOR_KEY), + vec![ + PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "inheritor key signed a HotSpend") +} + +#[simplex::test] +fn cold_spend_with_hot_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== ColdSpend, signed with the hot key instead of the cold key ===\n\n\ + Even though the two owner-held keys (hot and cold) belong to the same person\n\ + in practice, they aren't interchangeable and have different abilities.\n\ + Here we demonstrate this by trying to sign a ColdSpend with the hot key.\n" + ); + let result = spend_action( + &context, + "ColdSpend transaction, signed with the hot key instead of the cold key", + "ColdSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "hot key signed a ColdSpend") +} + +#[simplex::test] +fn cold_spend_with_inheritor_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!("=== ColdSpend, signed with the inheritor's key ===\n\nSame principle, third key.\n"); + let result = spend_action( + &context, + "ColdSpend transaction, signed with the inheritor's key", + "ColdSpend", + Sequence::default(), + scalar(INHERITOR_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "inheritor key signed a ColdSpend") +} + +#[simplex::test] +fn inherit_with_hot_key_after_wait_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== Inherit, signed with the hot key instead of the inheritor's key ===\n\n\ + Once again, the keys are not interchangeable and the key used must match the\n\ + specific spending path it is supposed to authorize.\n" + ); + let result = spend_action( + &context, + "Inherit transaction (declared wait satisfied), signed with the hot key", + "Inherit", + seq_distance(INHERITANCE_DISTANCE), + scalar(HOT_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "hot key signed an Inherit") +} + +#[simplex::test] +fn inherit_with_cold_key_after_wait_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!("=== Inherit, signed with the cold key ===\n\nSame principle, the other owner-held key.\n"); + let result = spend_action( + &context, + "Inherit transaction (declared wait satisfied), signed with the cold key", + "Inherit", + seq_distance(INHERITANCE_DISTANCE), + scalar(COLD_KEY), + vec![ + remainder_output(&context, 99_000), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "cold key signed an Inherit") +} + +// ===================================================================== +// `recursive_covenant()` violations. Each condition enforcing the +// structure of the covenant is broken on purpose by one of these tests. +// ===================================================================== + +#[simplex::test] +fn hot_spend_wrong_output_count_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend with only one output ===\n\n\ + `recursive_covenant()`'s first check is that `num_outputs()` equals 2.\n\ + Here, the entire 100,000 sats is provided as a single fee output.\n\ + Since this contract does not constrain the *value* of the fee output, a\n\ + stolen hot key could still leak most of the balance to miners this way.\n\ + This test only shows that skipping the continuation output entirely\n\ + is caught.\n" + ); + let result = spend_action( + &context, + "HotSpend transaction with a single (fee-only) output", + "HotSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![fee_output(100_000, context.get_network())], + 100_000, + ); + expect_assert_failure(result, "HotSpend with 1 output instead of 2") +} + +#[simplex::test] +fn hot_spend_wrong_continuation_script_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend recreating the wrong script ===\n\n\ + Here, output 0 sends the money to a script output different from this exact\n\ + covenant. `recursive_covenant()` compares the output's script hash against this\n\ + program's own script hash directly (not by trusting the witness), so a HotSpend\n\ + can't be used to smuggle the funds anywhere else while disguised as a routine\n\ + refresh.\n" + ); + let result = spend_action( + &context, + "HotSpend transaction recreating an unrelated script, not this covenant", + "HotSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![ + PartialOutput::new(unrelated_script(), 99_000, context.get_network().policy_asset()), + fee_output(1_000, context.get_network()), + ], + 100_000, + ); + expect_assert_failure(result, "HotSpend output 0 recreates the wrong script") +} + +#[simplex::test] +fn hot_spend_non_fee_second_output_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend with a real script where the fee output should be ===\n\n\ + Output 0 correctly recreates the covenant. Output 1, though, has a real\n\ + destination script instead of the empty scriptPubKey that marks an actual fee\n\ + output -- so `jet::output_is_fee(1)` returns `Some(false)`, `unwrap` yields\n\ + `false`, and the assertion fails on an ordinary `false`.\n" + ); + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + let result = spend_action( + &context, + "HotSpend transaction with a non-fee second output", + "HotSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![ + PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + PartialOutput::new(unrelated_script(), 1_000, context.get_network().policy_asset()), + ], + 100_000, + ); + expect_assert_failure(result, "HotSpend output 1 isn't a real fee output") +} + +#[simplex::test] +fn hot_spend_swapped_outputs_fails(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== HotSpend with the fee and continuation outputs swapped ===\n\n\ + The order of outputs can be meaningful and can be enforced with SimplicityHL.\n\ + Here, a real continuation output and a real fee output are present, but at the\n\ + wrong indices. Both of `recursive_covenant()`'s checks are position-specific\n\ + (`output_script_hash(0)`, `output_is_fee(1)`), not \"does some output satisfy\n\ + this somewhere,\" so swapping them fails.\n" + ); + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + let result = spend_action( + &context, + "HotSpend transaction with fee/continuation outputs swapped", + "HotSpend", + Sequence::default(), + scalar(HOT_KEY), + vec![ + fee_output(1_000, context.get_network()), + PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + ], + 100_000, + ); + expect_assert_failure(result, "HotSpend outputs in the wrong order") +} + +// ======================================================================== +// A realistic multi-hop spending chain: the covenant is refreshed twice by +// the owner and then gets broken out of with ColdSpend. +// ======================================================================== + +#[simplex::test] +fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext) -> anyhow::Result<()> { + println!( + "=== A full lifecycle: refresh, refresh again, then break out ===\n\n\ + Three chained transactions: the owner checks in twice with the hot key --\n\ + each one spending the UTXO the *previous* transaction created, proving the\n\ + covenant persists across several hops rather than just surviving a single\n\ + refresh, then the owner finally exits via the cold key. The covenant script\n\ + itself is the same at every step.\n" + ); + + let prog = program(); + let script = prog.get_script_pubkey(context.get_network()); + let network = context.get_network(); + let asset = network.policy_asset(); + + context.get_default_signer().send(script.clone(), 100_000)?; + + let hot_spend_hop = |amount_in: u64, hop_name: &str| -> anyhow::Result<()> { + let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let placeholder = SpendPathWitness::placeholder(&prog, "HotSpend")?; + + let mut ft = FinalTransaction::new(); + ft.add_program_input( + PartialInput::new(utxos[0].clone()), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), + RequiredSignature::None, + ); + ft.add_output(PartialOutput::new(script.clone(), amount_in - 1_000, asset)); + ft.add_output(fee_output(1_000, network)); + + let (mut pst, _secrets) = ft.extract_pst(); + let sig = sign_with_key(&pst, &prog, 0, network, scalar(HOT_KEY)); + let real_witness = placeholder.with_signature(sig); + + let pruned_witness = prog.as_ref().finalize(&pst, &real_witness.build_witness(), 0, network)?; + pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + + println!("Submitting {hop_name} (HotSpend, {amount_in} sats in, {} sats continuing)...", amount_in - 1_000); + let tx = pst.extract_tx()?; + let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + receipt.wait()?; + println!(" -> accepted and confirmed: {receipt}\n"); + Ok(()) + }; + + hot_spend_hop(100_000, "first refresh")?; + println!("Covenant still alive at 99,000 sats.\n"); + hot_spend_hop(99_000, "second refresh")?; + println!("Covenant still alive at 98,000 sats.\n"); + + // Final hop: break out via ColdSpend, spending the real UTXO the second refresh + // created. + let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let placeholder = SpendPathWitness::placeholder(&prog, "ColdSpend")?; + let mut ft = FinalTransaction::new(); + ft.add_program_input( + PartialInput::new(utxos[0].clone()), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), + RequiredSignature::None, + ); + ft.add_output(remainder_output(&context, 97_000)); + ft.add_output(fee_output(1_000, network)); + + let (mut pst, _secrets) = ft.extract_pst(); + let sig = sign_with_key(&pst, &prog, 0, network, scalar(COLD_KEY)); + let real_witness = placeholder.with_signature(sig); + let pruned_witness = prog.as_ref().finalize(&pst, &real_witness.build_witness(), 0, network)?; + pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + + println!("Submitting final hop (ColdSpend, breaking out of the covenant for good)..."); + let tx = pst.extract_tx()?; + let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + println!(" -> accepted: {receipt}\n"); + + println!( + "The covenant was recreated through two refresh cycles and then\n\ + deliberately ended. This is a somewhat realistic example of a\n\ + full lifecycle of an on-chain last-will covenant.\n" + ); + Ok(()) +} From 09b46e7aabf898bef843a299642281472755297e Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 30 Sep 2026 18:44:24 -0700 Subject: [PATCH 2/6] Use proper Simplex wallet/signer mechanisms Instead of hard-coding privkeys 1, 2, 3 and writing new signer logic, use proper wallet mnemonics and Simplex's built-in tools for this. Also tighten comments and narration. --- examples/last_will/README.md | 121 +++- examples/last_will/simf/last_will.simf | 16 +- examples/last_will/tests/last_will_test.rs | 772 +++++++++++---------- 3 files changed, 508 insertions(+), 401 deletions(-) diff --git a/examples/last_will/README.md b/examples/last_will/README.md index ca6feb15..68dda5e0 100644 --- a/examples/last_will/README.md +++ b/examples/last_will/README.md @@ -1,31 +1,78 @@ # Last Will Simplex Example -This example builds on [`examples/basic`](../basic) to show something -Bitcoin Script can't express at all: a *recursive covenant*. Where -`p2pk.simf` is a single spending condition, `last_will.simf` re-creates -itself on-chain across a whole chain of transactions, and offers three -different actions depending on which key is used. +This example builds on [`examples/basic`](../basic). Where `p2pk.simf` +there is a single spending condition, `last_will.simf` is a recursive +covenant: it re-creates itself on-chain across a series of transactions, +and offers three spending actions depending on which key signs. + +When its tests are run with `-v`, this example adds narration to give +more context about the actions each test is performing. ## Overview -`last_will.simf` implements a simple inheritance scheme with three -hard-coded keys: - -* **Inherit** -- after the covenant sits untouched for 1440 blocks - (relative timelock), the inheritor's key can withdraw the funds. -* **ColdSpend** -- the owner's cold key can break out of the covenant at - any time, with no wait required. -* **HotSpend** -- the owner's hot key can "check in" at any time, - resetting the inheritance clock. `refresh_spend` doesn't just check - a signature: it calls `recursive_covenant()`, which requires the - transaction to pay back into this exact same covenant script (output 0) - plus a real Elements fee output (output 1). This self-referential output - check, with the covenant examining its own outputs to constrain them, is - the mechanism with no Bitcoin Script equivalent. +`last_will.simf` implements a simple inheritance scheme with three keys, +supplied to the contract as `param::` arguments: + +* **Inherit**: after the covenant sits untouched for 1440 blocks + (relative timelock), the inheritor's key can withdraw the funds. The + upstream contract waits 25920 blocks, about 18 days at Liquid's 1-minute + block time; this example shortens the wait so the tests can mine it. +* **ColdSpend**: the owner's cold key can break out of the covenant at any + time, with no wait required. +* **HotSpend**: the owner's hot key can spend at any time, which resets + the inheritance clock. `refresh_spend` checks a signature and then calls + `recursive_covenant()`, which requires the transaction to pay back into + the same covenant script (output 0) alongside an Elements fee output + (output 1). Bitcoin Script has no equivalent, because it cannot inspect + a transaction's outputs. For Simplex compatibility, these actions are represented here as Either types (instantiated with Left/Right) rather than named `enum` values. +### Keys + +The three keys are ordinary Simplex wallets. `tests/last_will_test.rs` +builds one per party with `TestContext::create_signer`, each from its own +BIP-39 mnemonic, the same way `Simplex.toml`'s `[test] mnemonic` produces +the default test wallet: + +```rust +let inheritor = context.create_signer(INHERITOR_MNEMONIC); +``` + +Their public keys become the contract's `param::INHERITOR_PK`, +`param::COLD_PK` and `param::HOT_PK`. The covenant's script, and therefore +its address, follows from these three wallets. Spending uses Simplex's +signing path: the witness carries a placeholder signature at the branch +being taken, `RequiredSignature::witness_with_path` names where in the +witness that signature belongs, and the signer fills it in. + +The mnemonics in the test file are standard BIP-39 test vectors, so +their keys are public. Generate your own secrets for any deployment +holding real funds. + +### Outputs and fees + +The tests give Simplex the input and a change target. Simplex appends the +change output, then an Elements fee output, and works out both amounts: + +```rust +ft.add_change(ChangeOutput::new(script.clone())); +``` + +For a **HotSpend** the change target is the covenant's own script, which +produces the two outputs `recursive_covenant()` requires: output 0 paying +back into the same covenant, output 1 the fee. For **ColdSpend** and +**Inherit** the contract places no constraints on outputs, so the change +target is the spending party's wallet. + +Three tests check that the covenant rejects a malformed output set: one +with a single output, one with a non-fee output at index 1, and one with +the fee and continuation outputs swapped. A change target cannot produce +those shapes, because Simplex always appends change first and fee second, +so those tests build their outputs directly. They sign through +`SignerTrait::sign_program`. + The repository structure is the same shape as `examples/basic`: ```md @@ -44,19 +91,37 @@ root simplex build ``` -As with `examples/basic`, this generates `src/artifacts`, already declared as a module in -`src/lib.rs`. +As with `examples/basic`, this generates `src/artifacts`, already declared +as a module in `src/lib.rs`. ### Test ```bash -simplex test last_will_test -v +simplex test -v ``` -The `-v` option adds verbose output. The test suite includes narration, with each test -showing what it's attempting and how the network responded. Several tests expect a -rejection (wrong key, broken `recursive_covenant()` check, or inheritance claimed too -early). +The `-v` option adds verbose output. The test suite includes narration, +with each test showing what it's attempting and how the network +responded. Several tests expect a rejection (wrong key, broken +`recursive_covenant()` check, or inheritance claimed too early). + +One test, `inherit_after_wait_succeeds`, synthetically mines 1440 blocks +to make the inheritance wait real. This should be relatively rapid with +local `elementsd`. + +### Low-level tracing + +To see a local trace of the SimplicityHL contract executing in each test, +run the tests with an extra `-v`: + +```bash +simplex test -vv +``` -One test, `inherit_after_wait_succeeds`, synthetically mines 1440 blocks to make the -inheritance wait real. This should be relatively rapid with local `elementsd`. +This shows the individual +[jet](https://docs.simplicity-lang.org/documentation/jets) calls during +the course of the contract's execution, with their input and output +values. You can use this information to follow the contract's own logic +during a real transaction validation. For example, in the test cases that +exercise invalid spend conditions, you can see where the failures and +rejections happened. diff --git a/examples/last_will/simf/last_will.simf b/examples/last_will/simf/last_will.simf index ceead80e..390684f0 100644 --- a/examples/last_will/simf/last_will.simf +++ b/examples/last_will/simf/last_will.simf @@ -9,6 +9,9 @@ * Adapted from `last_will.simf` in BlockstreamResearch/SimplicityHL. * Changes: * + * - The three public keys are `param::` arguments rather than constants + * written into the source, so each deployment is bound to its own keys + * and the test suite can sign with ordinary Simplex wallets. * - The older Left/Right mechanism for selecting the spending action, * instead of an `enum`, is restored because Simplex currently does * not support contracts using `enum`. @@ -36,20 +39,17 @@ fn recursive_covenant() { } fn inherit_spend(inheritor_sig: Signature) { - let blocks_1440: Distance = 1440; - enforce_relative_distance(blocks_1440); - let inheritor_pk: Pubkey = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798; // 1 * G - checksig(inheritor_pk, inheritor_sig); + let inheritance_distance: Distance = 1440; + enforce_relative_distance(inheritance_distance); + checksig(param::INHERITOR_PK, inheritor_sig); } fn cold_spend(cold_sig: Signature) { - let cold_pk: Pubkey = 0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5; // 2 * G - checksig(cold_pk, cold_sig); + checksig(param::COLD_PK, cold_sig); } fn refresh_spend(hot_sig: Signature) { - let hot_pk: Pubkey = 0xf9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9; // 3 * G - checksig(hot_pk, hot_sig); + checksig(param::HOT_PK, hot_sig); recursive_covenant(); } diff --git a/examples/last_will/tests/last_will_test.rs b/examples/last_will/tests/last_will_test.rs index e2860ca8..c4140367 100644 --- a/examples/last_will/tests/last_will_test.rs +++ b/examples/last_will/tests/last_will_test.rs @@ -7,197 +7,249 @@ use last_will_example::artifacts::last_will::LastWillProgram; use last_will_example::artifacts::last_will::derived_last_will::{LastWillArguments, LastWillWitness}; -use simplex::program::{ProgramTrait, WitnessTrait}; -use simplex::simplicityhl::elements::pset::PartiallySignedTransaction; -use simplex::simplicityhl::elements::{Script, Sequence}; -use bitcoin_hashes::Hash as _; -use simplex::simplicityhl::simplicity::bitcoin::secp256k1::{self, Keypair, Message, SecretKey}; +use simplex::constants::DUMMY_SIGNATURE; use simplex::either::Either; -use simplex::simplicityhl::WitnessValues; +use simplex::program::{ProgramError, ProgramTrait, WitnessTrait}; use simplex::provider::SimplicityNetwork; -use simplex::transaction::{FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature}; - -// The contract hardcodes its own public keys in source (`1*G`, `2*G`, -// `3*G`, for inherit/cold/hot, respectively) rather than taking them as -// `param::` arguments. The matching private keys are these same small -// integers. These hard-coded keys are just for demonstration purposes, -// while real deployments would use `param::` values for the real public -// keys. -fn scalar(n: u8) -> [u8; 32] { - let mut bytes = [0u8; 32]; - bytes[31] = n; - bytes -} -const INHERITOR_KEY: u8 = 1; -const COLD_KEY: u8 = 2; -const HOT_KEY: u8 = 3; - +use simplex::signer::{Signer, SignerError, SignerTrait}; +use simplex::simplicityhl::elements::{Script, Sequence}; +use simplex::transaction::{ + ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, SigMessage, +}; + +// The three roles each get their own wallet, built from its own BIP-39 +// mnemonic. The contract takes their public keys as `param::` arguments, so +// the covenant is bound to these three wallets rather than to keys written +// into the contract source. +// +// These are standard BIP-39 test vectors. They are public knowledge and must +// never hold real funds. +const INHERITOR_MNEMONIC: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; +const COLD_MNEMONIC: &str = + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above"; +const HOT_MNEMONIC: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; + +// Must match `inheritance_distance` in `simf/last_will.simf`. +// // The original contract uses 25920 blocks, about 18 days at Liquid's -// 1-minute block time. This demo uses the smaller value 1440 (1 day) -// instead, purely for resource purposes. Mining tens of thousands of -// blocks on regtest may risk a `nextest` timeout. +// 1-minute block time. This demo uses 1440 (1 day) instead to keep the +// mining cheap: mining tens of thousands of blocks on regtest may risk a +// `nextest` timeout. const INHERITANCE_DISTANCE: u16 = 1440; -#[derive(Clone)] -struct SpendPathWitness { - variant: &'static str, - signature: [u8; 64], +// Every test funds a fresh covenant instance with the same amount, and the +// fee is worked out by Simplex rather than fixed here. +const FUNDING_AMOUNT: u64 = 100_000; + +/// The inheritor, the cold key holder and the hot key holder, as three +/// independent Simplex wallets. +struct Parties { + inheritor: Signer, + cold: Signer, + hot: Signer, } -impl SpendPathWitness { - fn placeholder(_program: &LastWillProgram, variant: &'static str) -> anyhow::Result { - Ok(Self { - variant, - signature: [0u8; 64], - }) +impl Parties { + fn new(context: &simplex::TestContext) -> Self { + Self { + inheritor: context.create_signer(INHERITOR_MNEMONIC), + cold: context.create_signer(COLD_MNEMONIC), + hot: context.create_signer(HOT_MNEMONIC), + } } - fn with_signature(&self, signature: [u8; 64]) -> Self { - Self { - variant: self.variant, - signature, + /// The contract's three `param::` public keys. Because the parameters are + /// baked into the compiled program, these three wallets determine the + /// covenant's script, and therefore its address. + fn arguments(&self) -> LastWillArguments { + LastWillArguments { + inheritor_pk: self.inheritor.get_schnorr_public_key().serialize(), + cold_pk: self.cold.get_schnorr_public_key().serialize(), + hot_pk: self.hot.get_schnorr_public_key().serialize(), } } -} -impl WitnessTrait for SpendPathWitness { - fn build_witness(&self) -> WitnessValues { - // `witness::INHERIT_OR_NOT` is an `Either>`, so each named spending path - // is a branch of that type. Simplex's codegen generates a - // real `LastWillWitness` for this shape, so all we have to do - // is name the branch; the conversion into Simplicity values - // is generated. Using the experimental `enum` feature would - // produce more readable SimplicityHL code, but is currently not - // supported in Simplex. - let inherit_or_not = match self.variant { - "Inherit" => Either::Left(self.signature), - "ColdSpend" => Either::Right(Either::Left(self.signature)), - "HotSpend" => Either::Right(Either::Right(self.signature)), - other => panic!("unknown spending path: {other}"), - }; - - LastWillWitness { inherit_or_not }.build_witness() + fn program(&self) -> LastWillProgram { + LastWillProgram::new(&self.arguments()) } -} -fn program() -> LastWillProgram { - LastWillProgram::new(&LastWillArguments {}) + fn script(&self, context: &simplex::TestContext) -> Script { + self.program().get_script_pubkey(context.get_network()) + } } -fn fee_output(amount: u64, network: &SimplicityNetwork) -> PartialOutput { - // Unlike Bitcoin, Elements/Liquid represents the transaction - // fee as an explicit output with an empty scriptPubKey. - // `recursive_covenant()` specifically checks for this at output - // index 1, using `jet::output_is_fee(1)`. - PartialOutput::new(Script::new(), amount, network.policy_asset()) +/// Which of the contract's three branches a spend takes. +/// +/// `witness::INHERIT_OR_NOT` is an `Either>`, so each spending path is a position in that nested type. +/// `witness()` puts a placeholder signature at that position, and +/// `sig_path()` names the same position for the signer, which replaces it. +/// An `enum` would read better in the contract, but Simplex does not support +/// contracts that use one. +#[derive(Clone, Copy)] +enum SpendPath { + Inherit, + ColdSpend, + HotSpend, } -/// The destination doesn't matter for demo purposes, so it just goes -/// back to the test wallet's own address. -fn remainder_output(context: &simplex::TestContext, amount: u64) -> PartialOutput { - PartialOutput::new( - context.get_default_signer().get_address().script_pubkey(), - amount, - context.get_network().policy_asset(), - ) -} +impl SpendPath { + fn witness(self) -> LastWillWitness { + self.witness_with(DUMMY_SIGNATURE) + } -/// `mine_until_height` issues a single RPC call asking the node to -/// generate however many blocks are needed in one shot. Asking for too -/// many blocks in one call can cause timeout problems, so we mine in -/// smaller batches here. -fn mine_up_to(context: &simplex::TestContext, target: u64) -> anyhow::Result<()> { - const BATCH: u64 = 500; - loop { - let current = u64::from(context.get_default_provider().fetch_tip_height()?); - if current >= target { - return Ok(()); + fn witness_with(self, signature: [u8; 64]) -> LastWillWitness { + LastWillWitness { + inherit_or_not: match self { + SpendPath::Inherit => Either::Left(signature), + SpendPath::ColdSpend => Either::Right(Either::Left(signature)), + SpendPath::HotSpend => Either::Right(Either::Right(signature)), + }, + } + } + + fn sig_path(self) -> &'static [&'static str] { + match self { + SpendPath::Inherit => &["Left"], + SpendPath::ColdSpend => &["Right", "Left"], + SpendPath::HotSpend => &["Right", "Right"], } - let next = (current + BATCH).min(target); - context.get_network_utils().mine_until_height(next)?; - println!(" ...mined to height {next} ({} to go)", target - next); } } -/// Signs `pst`'s input at `input_index` with a BIP-340 signature -/// over `jet::sig_all_hash()`, using the given raw 32-byte private -/// key scalar directly. Here, the possible private keys are -/// hard-coded. -fn sign_with_key( - pst: &PartiallySignedTransaction, - program: &LastWillProgram, - input_index: usize, - network: &SimplicityNetwork, - raw_privkey: [u8; 32], -) -> [u8; 64] { - let env = program - .as_ref() - .get_env(pst, input_index, network) - .expect("env should build"); - let msg = Message::from_digest(env.c_tx_env().sighash_all().to_byte_array()); - - let secp = secp256k1::Secp256k1::new(); - let secret_key = SecretKey::from_slice(&raw_privkey).expect("valid scalar"); - let keypair = Keypair::from_secret_key(&secp, &secret_key); - - secp.sign_schnorr(&msg, &keypair).serialize() +/// Where a spend sends the value it takes out of the covenant. +/// +/// Only `SpendPath::HotSpend` constrains this: `recursive_covenant()` requires +/// output 0 to pay back into the covenant. The other two paths don't constrain +/// outputs, so the value can go wherever the spender wants. +enum ChangeTo { + /// The spending party's own wallet. + SpenderWallet, + /// Back into the covenant itself, which is what a `HotSpend` requires. + Covenant, + /// Some other script, which a `HotSpend` must reject. + Other(Script), +} + +/// An Elements fee output. Unlike Bitcoin, Elements/Liquid represents the +/// transaction fee as an explicit output with an empty scriptPubKey, which +/// `recursive_covenant()` checks for at output index 1 with +/// `jet::output_is_fee(1)`. +fn fee_output(amount: u64, network: &SimplicityNetwork) -> PartialOutput { + PartialOutput::new(Script::new(), amount, network.policy_asset()) } -/// Funds a fresh covenant instance, builds a `FinalTransaction` -/// spending it with the given `variant`/`sequence`/`outputs`, signs it -/// with `signing_key`, finalizes locally, and broadcasts. Returns the -/// local-execution or broadcast error (if any) unexamined, so callers -/// can assert either success or a specific failure result. +/// Funds a fresh covenant instance, then spends it via `path`, signed by +/// `signer`. /// -/// `description` is printed immediately before the actual submission, -/// and (only on success) immediately after. The `nextest` wrapper will -/// capture the output and print it only when the test completes. +/// `finalize_strict` appends the `change_to` output, then an Elements fee +/// output, and sets both amounts, giving the transaction two outputs. For a +/// `SpendPath::HotSpend` those are the two outputs `recursive_covenant()` +/// requires: output 0 back into the covenant, output 1 the fee. +/// +/// `RequiredSignature::witness_with_path` names the witness and the position +/// within it that the signature goes to. +/// +/// Returns the local-execution or broadcast error (if any) unexamined, so +/// callers can assert either success or a specific failure result. fn spend_action( context: &simplex::TestContext, + parties: &Parties, description: &str, - variant: &'static str, + path: SpendPath, sequence: Sequence, - signing_key: [u8; 32], - outputs: Vec, - utxo_amount: u64, + signer: &Signer, + change_to: ChangeTo, ) -> anyhow::Result { - let prog = program(); + let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - context.get_default_signer().send(script.clone(), utxo_amount)?; + context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + + let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + + let mut ft = FinalTransaction::new(); + ft.add_program_input( + PartialInput::new(utxos[0].clone()).with_sequence(sequence), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), + RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), + ); + ft.add_change(ChangeOutput::new(match change_to { + ChangeTo::SpenderWallet => signer.get_address().script_pubkey(), + ChangeTo::Covenant => script, + ChangeTo::Other(other) => other, + })); + + println!("Submitting {description}..."); + + let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; + let (tx, _fee) = signer.finalize_strict(&ft, fee_rate)?; + + // `finalize_strict` encapsulates the sign-and-satisfy sequence. The + // individual actions involved can be broken out if necessary; see below + // in spend_with_exact_outputs() for an example. + let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + println!(" -> accepted: {receipt}"); + Ok(receipt.to_string()) +} + +/// Spends a covenant UTXO with a transaction whose outputs are given exactly +/// as `outputs`, with no change output and no automatic fee output. +/// +/// Every caller is a test that expects the spend to be rejected. Spends that +/// are meant to succeed go through `spend_action`. +/// +/// The output-shape tests below build transactions that violate +/// `recursive_covenant()`: one output instead of two, a non-fee output at +/// index 1, and the fee and continuation outputs swapped. `spend_action` +/// cannot express those, because +/// `finalize_strict` always appends its own change and fee outputs and takes +/// the amounts from what is left over, so these transactions are balanced by +/// hand. +/// +/// `SignerTrait::sign_program` signs the input, and `LastWillWitness` carries +/// the signature into the right branch. +fn spend_with_exact_outputs( + context: &simplex::TestContext, + parties: &Parties, + description: &str, + path: SpendPath, + sequence: Sequence, + signer: &Signer, + outputs: Vec, +) -> anyhow::Result { + let prog = parties.program(); + let network = context.get_network(); + let script = prog.get_script_pubkey(network); + + context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let placeholder = SpendPathWitness::placeholder(&prog, variant)?; let mut ft = FinalTransaction::new(); ft.add_program_input( PartialInput::new(utxos[0].clone()).with_sequence(sequence), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), RequiredSignature::None, ); for output in outputs { ft.add_output(output); } - let (mut pst, _secrets) = ft.extract_pst(); + println!("Submitting {description}..."); - let sig = sign_with_key(&pst, &prog, 0, context.get_network(), signing_key); - let real_witness = placeholder.with_signature(sig); + // Signing and local execution are kept apart from broadcast deliberately: + // `ProgramTrait::finalize` runs the Simplicity program locally, which is + // where a failed `assert!` surfaces, before the transaction reaches the + // network; only then is it broadcast, where consensus rules such as + // relative locktimes apply. Several tests depend on telling those apart. + let (mut pst, _secrets) = ft.extract_pst(); - println!("Submitting {description}..."); + let signature = signer.sign_program(&pst, prog.as_ref(), 0, network, None, &SigMessage::Sighash)?; + let witness = path.witness_with(signature.serialize()).build_witness(); - // Local Simplicity execution: this is where an - // `AssertFailed`/`PrunedBranch` case would surface, before the - // transaction ever reaches the network. A failure here (or at - // broadcast, below) propagates via `?` without printing a "-> - // rejected" line itself -- that's the caller's job, since callers - // differ on how they want to describe an expected rejection (a - // specific error-message check, a generic assertion-failure check, - // and so on). - let pruned_witness = prog - .as_ref() - .finalize(&pst, &real_witness.build_witness(), 0, context.get_network())?; + let pruned_witness = prog.as_ref().finalize(&pst, &witness, 0, network)?; pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); let tx = pst.extract_tx()?; @@ -206,24 +258,38 @@ fn spend_action( Ok(receipt.to_string()) } -fn seq_distance(blocks: u16) -> Sequence { - Sequence::from_consensus(u32::from(blocks)) +/// Whether a `ProgramError` represents the Simplicity program rejecting the +/// spend, as opposed to any other failure. An `assert!` that fails shows up +/// while the Bit Machine runs, which is either during execution proper or +/// during the pruning pass that `finalize` performs. +fn is_program_execution_failure(error: &ProgramError) -> bool { + matches!(error, ProgramError::Execution(_) | ProgramError::Pruning(_)) } -/// Asserts that `spend_action`'s result failed during LOCAL Simplicity -/// execution (`AssertFailed`) rather than succeeding or failing only -/// once it reached a node. Every test that expects a spend to be -/// rejected outright (wrong key, or a `recursive_covenant()` violation) -/// fails this way: the contract's own logic catches it before broadcast -/// is ever attempted. +/// Asserts that a spend helper's result failed during local Simplicity +/// execution rather than succeeding or failing only once it reached a +/// node. Every test that expects a spend to be rejected outright (wrong +/// key, or a `recursive_covenant()` violation) fails this way: the +/// contract's own logic catches it before broadcast is ever attempted. fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> anyhow::Result<()> { match result { - Ok(receipt) => anyhow::bail!("expected local execution to reject this ({what_was_wrong}), but it broadcast: {receipt}"), + Ok(receipt) => { + anyhow::bail!("expected local execution to reject this ({what_was_wrong}), but it broadcast: {receipt}") + } Err(err) => { - let msg = err.to_string(); + // Match the typed error rather than message text from a transitive + // dependency. The two spending paths surface it differently: + // `finalize_strict` reports a covenant input that did not execute, + // while `ProgramTrait::finalize` returns the program error directly. + let is_execution_failure = match err.downcast_ref::() { + Some(SignerError::CovenantExecution { .. }) => true, + Some(SignerError::Program(program_error)) => is_program_execution_failure(program_error), + _ => err.downcast_ref::().is_some_and(is_program_execution_failure), + }; + anyhow::ensure!( - msg.contains("Jet failed during execution"), - "expected an assertion failure ({what_was_wrong}), got a different error: {msg}" + is_execution_failure, + "expected an assertion failure ({what_was_wrong}), got a different error: {err}" ); println!(" -> rejected locally, as expected: {what_was_wrong}\n"); Ok(()) @@ -231,100 +297,94 @@ fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) - } } -/// A simple non-covenant script (OP_TRUE), as an example of a script -/// that definitely *is not* equal to this covenant (to demonstrate -/// the covenant's ability to distinguish copies of itself from other -/// scripts). +/// OP_TRUE: a script that is definitely not this covenant. fn unrelated_script() -> Script { Script::from(vec![0x51]) } #[simplex::test] fn cold_spend_breaks_out_of_the_covenant(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== ColdSpend: the owner cancels the covenant entirely ===\n\n\ - `cold_spend` is the simplest of the three paths -- just a signature check\n\ - against the hardcoded cold key (2*G). Whoever holds the cold private key\n\ + "\n=== ColdSpend: the owner cancels the covenant entirely ===\n\n\ + `cold_spend` is the simplest of the three paths, just a signature check\n\ + against `param::COLD_PK`. Whoever holds the cold private key\n\ can withdraw the funds from this covenant at any time, ending it.\n" ); spend_action( &context, + &parties, "ColdSpend transaction, signed with the cold key", - "ColdSpend", + SpendPath::ColdSpend, Sequence::default(), - scalar(COLD_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.cold, + ChangeTo::SpenderWallet, )?; Ok(()) } #[simplex::test] fn hot_spend_refreshes_the_covenant(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend: the owner checks in, and the covenant is refreshed ===\n\n\ - This is the core covenant example: `refresh_spend` checks a signature but also\n\ - calls `recursive_covenant()`, which requires the transaction to have exactly\n\ - two outputs: output 0 must be a NEW UTXO paying back to this exact same\n\ - covenant script (verified by comparing commitment hashes, not by trusting the\n\ - witness), and output 1 must be a fee output. This is how a Simplicity covenant\n\ - 'lives on' across transactions, which Bitcoin Script can't express because it\n\ - has no way to inspect or constrain transaction outputs.\n" + "\n=== HotSpend: the owner checks in, and the covenant is refreshed ===\n\n\ + `refresh_spend` checks a signature and then calls `recursive_covenant()`,\n\ + which requires the transaction to have two outputs. Output 0 must be a new\n\ + UTXO paying back to the same covenant script, verified by comparing\n\ + commitment hashes rather than by trusting the witness. Output 1 must be a\n\ + fee output. These two checks carry the covenant across transactions.\n\ + Bitcoin Script has no equivalent, because it cannot inspect or constrain\n\ + transaction outputs.\n\n\ + The test doesn't assemble those two outputs itself: it hands Simplex the\n\ + covenant's own script as the change target, and Simplex emits the change\n\ + output followed by the fee output, working out both amounts.\n" ); - let prog = program(); - let script = prog.get_script_pubkey(context.get_network()); - spend_action( &context, + &parties, "HotSpend transaction, signed with the hot key", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(HOT_KEY), - vec![ - PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.hot, + ChangeTo::Covenant, )?; println!( "The covenant's {INHERITANCE_DISTANCE}-block inheritance clock has effectively\n\ - been reset -- the new UTXO this transaction creates is a fresh instance of the\n\ - same covenant, with the same overall rules as the original.\n" + been reset. The new UTXO this transaction creates is a fresh instance of the\n\ + same covenant, carrying the same rules as the original.\n" ); Ok(()) } #[simplex::test] fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== Inherit, attempted immediately: rejected by the network ===\n\n\ + "\n=== Inherit, attempted immediately: rejected by the network ===\n\n\ The inheritor declares a sequence number claiming the full {INHERITANCE_DISTANCE}-block\n\ - wait has passed. `enforce_relative_distance` only checks that the DECLARED\n\ - value is large enough -- it can't see the real chain state, so local execution\n\ - succeeds. But we deliberately don't mine any blocks first, so the real distance\n\ - elapsed since funding falls short of the declared value. The node itself then\n\ - catches this and rejects the transaction as too early.\n" + wait has passed. `enforce_relative_distance` only checks that the declared\n\ + value is large enough. It cannot see the chain state, so local execution\n\ + succeeds. No blocks are mined first, so the distance actually elapsed since\n\ + funding falls short of the declared value, and the node rejects the\n\ + transaction as too early.\n" ); let result = spend_action( &context, + &parties, &format!( "Inherit transaction, declaring the full {INHERITANCE_DISTANCE}-block wait, with no blocks actually mined" ), - "Inherit", - seq_distance(INHERITANCE_DISTANCE), - scalar(INHERITOR_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + SpendPath::Inherit, + Sequence::from_height(INHERITANCE_DISTANCE), + &parties.inheritor, + ChangeTo::SpenderWallet, ); match result { @@ -349,8 +409,10 @@ fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Resul #[simplex::test] fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== Inherit successfully: fast-forwarding {INHERITANCE_DISTANCE} blocks ===\n\n\ + "\n=== Inherit successfully: fast-forwarding {INHERITANCE_DISTANCE} blocks ===\n\n\ At Liquid's 1-minute block time, {INHERITANCE_DISTANCE} blocks is about a day,\n\ against the 25920 blocks (~18 days) the upstream contract asks for. In a\n\ real deployment, the inheritor has to wait the correct amount of time\n\ @@ -359,37 +421,29 @@ fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result< immediately, ignoring its default blocktime delay.\n" ); - let prog = program(); + let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - context.get_default_signer().send(script.clone(), 100_000)?; + context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; let target = context.get_default_provider().fetch_tip_height()? as u64 + u64::from(INHERITANCE_DISTANCE); - mine_up_to(&context, target)?; + context.get_network_utils().mine_until_height(target)?; println!("Mined up to height {target}.\n"); let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let placeholder = SpendPathWitness::placeholder(&prog, "Inherit")?; + let path = SpendPath::Inherit; let mut ft = FinalTransaction::new(); ft.add_program_input( - PartialInput::new(utxos[0].clone()).with_sequence(seq_distance(INHERITANCE_DISTANCE)), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), - RequiredSignature::None, + PartialInput::new(utxos[0].clone()).with_sequence(Sequence::from_height(INHERITANCE_DISTANCE)), + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), + RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), ); - ft.add_output(remainder_output(&context, 99_000)); - ft.add_output(fee_output(1_000, context.get_network())); - - let (mut pst, _secrets) = ft.extract_pst(); - let sig = sign_with_key(&pst, &prog, 0, context.get_network(), scalar(INHERITOR_KEY)); - let real_witness = placeholder.with_signature(sig); - - let pruned_witness = prog - .as_ref() - .finalize(&pst, &real_witness.build_witness(), 0, context.get_network())?; - pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + ft.add_change(ChangeOutput::new(parties.inheritor.get_address().script_pubkey())); println!("Submitting the same Inherit transaction as before, now that the wait is real..."); - let tx = pst.extract_tx()?; + + let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; + let (tx, _fee) = parties.inheritor.finalize_strict(&ft, fee_rate)?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; println!(" -> accepted: {receipt}"); @@ -402,135 +456,129 @@ fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result< // ===================================================================== // Wrong-key tests: each of the three paths checks a signature against a -// specific hardcoded public key. These tests deliberately mix up these -// keys and so fail during local execution. +// specific one of the contract's three `param::` public keys. These tests +// deliberately sign with the wrong party's wallet and so fail during local +// execution. // ===================================================================== #[simplex::test] fn hot_spend_with_cold_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend, signed with the cold key instead of the hot key ===\n\n\ - Correctly structured HotSpend, but signed with the cold private key (2)\n\ - instead of the expected hot key (3). The signature will be rejected.\n" + "\n=== HotSpend, signed with the cold key instead of the hot key ===\n\n\ + Correctly structured HotSpend, but signed by the cold wallet instead of\n\ + the hot wallet, so the signature does not verify against `param::HOT_PK`.\n" ); - let prog = program(); - let script = prog.get_script_pubkey(context.get_network()); let result = spend_action( &context, + &parties, "HotSpend transaction, signed with the cold key instead of the hot key", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(COLD_KEY), - vec![ - PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.cold, + ChangeTo::Covenant, ); expect_assert_failure(result, "cold key signed a HotSpend") } #[simplex::test] fn hot_spend_with_inheritor_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend, signed with the inheritor's key instead of the hot key ===\n\n\ - Same idea as the cold-key case, with the third key instead: the inheritor\n\ - cannot use their key to act as the owner and bypass the timelock.\n" + "\n=== HotSpend, signed with the inheritor's key instead of the hot key ===\n\n\ + Same idea as the cold-key case, but with the inheritor's wallet: the\n\ + inheritor cannot act as the owner and bypass the timelock.\n" ); - let prog = program(); - let script = prog.get_script_pubkey(context.get_network()); let result = spend_action( &context, + &parties, "HotSpend transaction, signed with the inheritor's key instead of the hot key", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(INHERITOR_KEY), - vec![ - PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.inheritor, + ChangeTo::Covenant, ); expect_assert_failure(result, "inheritor key signed a HotSpend") } #[simplex::test] fn cold_spend_with_hot_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== ColdSpend, signed with the hot key instead of the cold key ===\n\n\ + "\n=== ColdSpend, signed with the hot key instead of the cold key ===\n\n\ Even though the two owner-held keys (hot and cold) belong to the same person\n\ in practice, they aren't interchangeable and have different abilities.\n\ Here we demonstrate this by trying to sign a ColdSpend with the hot key.\n" ); let result = spend_action( &context, + &parties, "ColdSpend transaction, signed with the hot key instead of the cold key", - "ColdSpend", + SpendPath::ColdSpend, Sequence::default(), - scalar(HOT_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.hot, + ChangeTo::SpenderWallet, ); expect_assert_failure(result, "hot key signed a ColdSpend") } #[simplex::test] fn cold_spend_with_inheritor_key_fails(context: simplex::TestContext) -> anyhow::Result<()> { - println!("=== ColdSpend, signed with the inheritor's key ===\n\nSame principle, third key.\n"); + let parties = Parties::new(&context); + + println!( + "\n=== ColdSpend, signed with the inheritor's key ===\n\n\ + The same principle applies to the third of the three wallets.\n" + ); let result = spend_action( &context, + &parties, "ColdSpend transaction, signed with the inheritor's key", - "ColdSpend", + SpendPath::ColdSpend, Sequence::default(), - scalar(INHERITOR_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.inheritor, + ChangeTo::SpenderWallet, ); expect_assert_failure(result, "inheritor key signed a ColdSpend") } #[simplex::test] fn inherit_with_hot_key_after_wait_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== Inherit, signed with the hot key instead of the inheritor's key ===\n\n\ + "\n=== Inherit, signed with the hot key instead of the inheritor's key ===\n\n\ Once again, the keys are not interchangeable and the key used must match the\n\ specific spending path it is supposed to authorize.\n" ); let result = spend_action( &context, + &parties, "Inherit transaction (declared wait satisfied), signed with the hot key", - "Inherit", - seq_distance(INHERITANCE_DISTANCE), - scalar(HOT_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + SpendPath::Inherit, + Sequence::from_height(INHERITANCE_DISTANCE), + &parties.hot, + ChangeTo::SpenderWallet, ); expect_assert_failure(result, "hot key signed an Inherit") } #[simplex::test] fn inherit_with_cold_key_after_wait_fails(context: simplex::TestContext) -> anyhow::Result<()> { - println!("=== Inherit, signed with the cold key ===\n\nSame principle, the other owner-held key.\n"); + let parties = Parties::new(&context); + + println!("\n=== Inherit, signed with the cold key ===\n\nThe same applies to the other owner-held key.\n"); let result = spend_action( &context, + &parties, "Inherit transaction (declared wait satisfied), signed with the cold key", - "Inherit", - seq_distance(INHERITANCE_DISTANCE), - scalar(COLD_KEY), - vec![ - remainder_output(&context, 99_000), - fee_output(1_000, context.get_network()), - ], - 100_000, + SpendPath::Inherit, + Sequence::from_height(INHERITANCE_DISTANCE), + &parties.cold, + ChangeTo::SpenderWallet, ); expect_assert_failure(result, "cold key signed an Inherit") } @@ -542,31 +590,35 @@ fn inherit_with_cold_key_after_wait_fails(context: simplex::TestContext) -> anyh #[simplex::test] fn hot_spend_wrong_output_count_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend with only one output ===\n\n\ + "\n=== HotSpend with only one output ===\n\n\ `recursive_covenant()`'s first check is that `num_outputs()` equals 2.\n\ Here, the entire 100,000 sats is provided as a single fee output.\n\ - Since this contract does not constrain the *value* of the fee output, a\n\ + Since this contract does not constrain the value of the fee output, a\n\ stolen hot key could still leak most of the balance to miners this way.\n\ This test only shows that skipping the continuation output entirely\n\ is caught.\n" ); - let result = spend_action( + let result = spend_with_exact_outputs( &context, + &parties, "HotSpend transaction with a single (fee-only) output", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(HOT_KEY), - vec![fee_output(100_000, context.get_network())], - 100_000, + &parties.hot, + vec![fee_output(FUNDING_AMOUNT, context.get_network())], ); expect_assert_failure(result, "HotSpend with 1 output instead of 2") } #[simplex::test] fn hot_spend_wrong_continuation_script_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend recreating the wrong script ===\n\n\ + "\n=== HotSpend recreating the wrong script ===\n\n\ Here, output 0 sends the money to a script output different from this exact\n\ covenant. `recursive_covenant()` compares the output's script hash against this\n\ program's own script hash directly (not by trusting the witness), so a HotSpend\n\ @@ -575,156 +627,146 @@ fn hot_spend_wrong_continuation_script_fails(context: simplex::TestContext) -> a ); let result = spend_action( &context, + &parties, "HotSpend transaction recreating an unrelated script, not this covenant", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(HOT_KEY), - vec![ - PartialOutput::new(unrelated_script(), 99_000, context.get_network().policy_asset()), - fee_output(1_000, context.get_network()), - ], - 100_000, + &parties.hot, + ChangeTo::Other(unrelated_script()), ); expect_assert_failure(result, "HotSpend output 0 recreates the wrong script") } #[simplex::test] fn hot_spend_non_fee_second_output_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend with a real script where the fee output should be ===\n\n\ + "\n=== HotSpend with a real script where the fee output should be ===\n\n\ Output 0 correctly recreates the covenant. Output 1, though, has a real\n\ destination script instead of the empty scriptPubKey that marks an actual fee\n\ - output -- so `jet::output_is_fee(1)` returns `Some(false)`, `unwrap` yields\n\ - `false`, and the assertion fails on an ordinary `false`.\n" + output, so `jet::output_is_fee(1)` is `false` and the assertion fails.\n" ); - let prog = program(); - let script = prog.get_script_pubkey(context.get_network()); - let result = spend_action( + let script = parties.script(&context); + let result = spend_with_exact_outputs( &context, + &parties, "HotSpend transaction with a non-fee second output", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(HOT_KEY), + &parties.hot, vec![ - PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + PartialOutput::new(script, 99_000, context.get_network().policy_asset()), PartialOutput::new(unrelated_script(), 1_000, context.get_network().policy_asset()), ], - 100_000, ); expect_assert_failure(result, "HotSpend output 1 isn't a real fee output") } #[simplex::test] fn hot_spend_swapped_outputs_fails(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== HotSpend with the fee and continuation outputs swapped ===\n\n\ - The order of outputs can be meaningful and can be enforced with SimplicityHL.\n\ - Here, a real continuation output and a real fee output are present, but at the\n\ + "\n=== HotSpend with the fee and continuation outputs swapped ===\n\n\ + A real continuation output and a real fee output are both present, but at the\n\ wrong indices. Both of `recursive_covenant()`'s checks are position-specific\n\ (`output_script_hash(0)`, `output_is_fee(1)`), not \"does some output satisfy\n\ this somewhere,\" so swapping them fails.\n" ); - let prog = program(); - let script = prog.get_script_pubkey(context.get_network()); - let result = spend_action( + let script = parties.script(&context); + let result = spend_with_exact_outputs( &context, + &parties, "HotSpend transaction with fee/continuation outputs swapped", - "HotSpend", + SpendPath::HotSpend, Sequence::default(), - scalar(HOT_KEY), + &parties.hot, vec![ fee_output(1_000, context.get_network()), - PartialOutput::new(script.clone(), 99_000, context.get_network().policy_asset()), + PartialOutput::new(script, 99_000, context.get_network().policy_asset()), ], - 100_000, ); expect_assert_failure(result, "HotSpend outputs in the wrong order") } -// ======================================================================== +// ===================================================================== // A realistic multi-hop spending chain: the covenant is refreshed twice by // the owner and then gets broken out of with ColdSpend. -// ======================================================================== +// ===================================================================== #[simplex::test] fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext) -> anyhow::Result<()> { + let parties = Parties::new(&context); + println!( - "=== A full lifecycle: refresh, refresh again, then break out ===\n\n\ - Three chained transactions: the owner checks in twice with the hot key --\n\ - each one spending the UTXO the *previous* transaction created, proving the\n\ - covenant persists across several hops rather than just surviving a single\n\ - refresh, then the owner finally exits via the cold key. The covenant script\n\ - itself is the same at every step.\n" + "\n=== A full lifecycle: refresh, refresh again, then break out ===\n\n\ + Three chained transactions. The owner checks in twice with the hot key,\n\ + each spend consuming the UTXO the previous transaction created, so the\n\ + covenant persists across several hops. The owner then exits via the cold\n\ + key. The covenant script is the same at every step.\n" ); - let prog = program(); + let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - let network = context.get_network(); - let asset = network.policy_asset(); - context.get_default_signer().send(script.clone(), 100_000)?; + context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; - let hot_spend_hop = |amount_in: u64, hop_name: &str| -> anyhow::Result<()> { + // Returns what is left in the covenant after the hop, since Simplex works + // out the fee rather than the test fixing it in advance. + let hot_spend_hop = |amount_in: u64, hop_name: &str| -> anyhow::Result { let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let placeholder = SpendPathWitness::placeholder(&prog, "HotSpend")?; + let path = SpendPath::HotSpend; let mut ft = FinalTransaction::new(); ft.add_program_input( PartialInput::new(utxos[0].clone()), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), - RequiredSignature::None, + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), + RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), ); - ft.add_output(PartialOutput::new(script.clone(), amount_in - 1_000, asset)); - ft.add_output(fee_output(1_000, network)); - - let (mut pst, _secrets) = ft.extract_pst(); - let sig = sign_with_key(&pst, &prog, 0, network, scalar(HOT_KEY)); - let real_witness = placeholder.with_signature(sig); + // Paying the change back into the covenant's own script gives + // `recursive_covenant()` the outputs it requires: Simplex emits that + // output first and the fee output second, and works out both amounts. + ft.add_change(ChangeOutput::new(script.clone())); - let pruned_witness = prog.as_ref().finalize(&pst, &real_witness.build_witness(), 0, network)?; - pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + println!("Submitting {hop_name} (HotSpend, {amount_in} sats in)..."); - println!("Submitting {hop_name} (HotSpend, {amount_in} sats in, {} sats continuing)...", amount_in - 1_000); - let tx = pst.extract_tx()?; + let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; + let (tx, fee) = parties.hot.finalize_strict(&ft, fee_rate)?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + // Each hop must confirm before the next one can spend its output. receipt.wait()?; - println!(" -> accepted and confirmed: {receipt}\n"); - Ok(()) + println!(" -> accepted and confirmed: {receipt} ({fee} sats fee)\n"); + Ok(amount_in - fee) }; - hot_spend_hop(100_000, "first refresh")?; - println!("Covenant still alive at 99,000 sats.\n"); - hot_spend_hop(99_000, "second refresh")?; - println!("Covenant still alive at 98,000 sats.\n"); + let after_first = hot_spend_hop(FUNDING_AMOUNT, "first refresh")?; + println!("Covenant still alive at {after_first} sats.\n"); + let after_second = hot_spend_hop(after_first, "second refresh")?; + println!("Covenant still alive at {after_second} sats.\n"); // Final hop: break out via ColdSpend, spending the real UTXO the second refresh // created. let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let placeholder = SpendPathWitness::placeholder(&prog, "ColdSpend")?; + let path = SpendPath::ColdSpend; let mut ft = FinalTransaction::new(); ft.add_program_input( PartialInput::new(utxos[0].clone()), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(placeholder.clone())), - RequiredSignature::None, + ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), + RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), ); - ft.add_output(remainder_output(&context, 97_000)); - ft.add_output(fee_output(1_000, network)); - - let (mut pst, _secrets) = ft.extract_pst(); - let sig = sign_with_key(&pst, &prog, 0, network, scalar(COLD_KEY)); - let real_witness = placeholder.with_signature(sig); - let pruned_witness = prog.as_ref().finalize(&pst, &real_witness.build_witness(), 0, network)?; - pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); + ft.add_change(ChangeOutput::new(parties.cold.get_address().script_pubkey())); println!("Submitting final hop (ColdSpend, breaking out of the covenant for good)..."); - let tx = pst.extract_tx()?; + + let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; + let (tx, _fee) = parties.cold.finalize_strict(&ft, fee_rate)?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; println!(" -> accepted: {receipt}\n"); println!( - "The covenant was recreated through two refresh cycles and then\n\ - deliberately ended. This is a somewhat realistic example of a\n\ - full lifecycle of an on-chain last-will covenant.\n" + "The covenant was recreated through two refresh cycles and then ended\n\ + on purpose.\n" ); Ok(()) } From 58af9de5f758b86804c9ac71ba49cfdac42f6dc5 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 7 Oct 2026 15:52:20 -0700 Subject: [PATCH 3/6] Improve idiomaticity and SDK usage for last_will demo --- examples/last_will/README.md | 36 +- examples/last_will/simf/last_will.simf | 22 +- examples/last_will/tests/last_will_test.rs | 369 +++++++++++---------- 3 files changed, 226 insertions(+), 201 deletions(-) diff --git a/examples/last_will/README.md b/examples/last_will/README.md index 68dda5e0..eea474d9 100644 --- a/examples/last_will/README.md +++ b/examples/last_will/README.md @@ -10,13 +10,15 @@ more context about the actions each test is performing. ## Overview -`last_will.simf` implements a simple inheritance scheme with three keys, -supplied to the contract as `param::` arguments: - -* **Inherit**: after the covenant sits untouched for 1440 blocks - (relative timelock), the inheritor's key can withdraw the funds. The - upstream contract waits 25920 blocks, about 18 days at Liquid's 1-minute - block time; this example shortens the wait so the tests can mine it. +`last_will.simf` implements a simple inheritance scheme with three keys +and an inheritance distance, all supplied to the contract as `param::` +arguments: + +* **Inherit**: after the covenant sits untouched for + `param::INHERITANCE_DISTANCE` blocks (relative timelock), the inheritor's + key can withdraw the funds. The upstream contract hardcodes 25920 blocks, + about 18 days at Liquid's 1-minute block time; this example passes 1440 + instead, so the tests can mine it. * **ColdSpend**: the owner's cold key can break out of the covenant at any time, with no wait required. * **HotSpend**: the owner's hot key can spend at any time, which resets @@ -41,11 +43,17 @@ let inheritor = context.create_signer(INHERITOR_MNEMONIC); ``` Their public keys become the contract's `param::INHERITOR_PK`, -`param::COLD_PK` and `param::HOT_PK`. The covenant's script, and therefore -its address, follows from these three wallets. Spending uses Simplex's -signing path: the witness carries a placeholder signature at the branch -being taken, `RequiredSignature::witness_with_path` names where in the -witness that signature belongs, and the signer fills it in. +`param::COLD_PK` and `param::HOT_PK`, alongside +`param::INHERITANCE_DISTANCE`. The covenant's script, and therefore its +address, is derived from all four; +`hot_spend_wrong_continuation_script_fails` relies on that, refreshing into +a covenant with the same three keys but a doubled inheritance distance, +which the contract rejects. + +Spending uses Simplex's signing path: the witness carries a placeholder +signature at the branch being taken, +`RequiredSignature::witness_with_path` says where the signature belongs, +and the signer fills it in. The mnemonics in the test file are standard BIP-39 test vectors, so their keys are public. Generate your own secrets for any deployment @@ -70,8 +78,8 @@ Three tests check that the covenant rejects a malformed output set: one with a single output, one with a non-fee output at index 1, and one with the fee and continuation outputs swapped. A change target cannot produce those shapes, because Simplex always appends change first and fee second, -so those tests build their outputs directly. They sign through -`SignerTrait::sign_program`. +so those tests list their outputs directly and sign with `Signer::sign_tx`, +which appends nothing. The repository structure is the same shape as `examples/basic`: diff --git a/examples/last_will/simf/last_will.simf b/examples/last_will/simf/last_will.simf index 390684f0..e8a46315 100644 --- a/examples/last_will/simf/last_will.simf +++ b/examples/last_will/simf/last_will.simf @@ -2,22 +2,23 @@ * LAST WILL * * The inheritor can spend the coins if the owner doesn't move them for - * 1440 blocks. The owner has to repeat the covenant when he moves the - * coins with his hot key. The owner can break out of the covenant with - * his cold key. + * `param::INHERITANCE_DISTANCE` blocks. The owner has to repeat the + * covenant when he moves the coins with his hot key. The owner can break + * out of the covenant with his cold key. * * Adapted from `last_will.simf` in BlockstreamResearch/SimplicityHL. * Changes: * - * - The three public keys are `param::` arguments rather than constants - * written into the source, so each deployment is bound to its own keys - * and the test suite can sign with ordinary Simplex wallets. + * - The three public keys and the inheritance distance are `param::` + * arguments rather than constants written into the source, so each + * deployment is bound to its own keys and picks its own wait, and the + * test suite can sign with ordinary Simplex wallets. * - The older Left/Right mechanism for selecting the spending action, * instead of an `enum`, is restored because Simplex currently does * not support contracts using `enum`. - * - The inheritance distance is reduced to 1440 blocks (1 day on Liquid) - * to reduce CPU requirements for mining these blocks rapidly during the - * demo. + * - The demo passes 1440 blocks (1 day on Liquid) as the inheritance + * distance instead of the original 25920, so the tests can mine the + * wait quickly. */ use crate::timelocks::enforce_relative_distance; @@ -39,8 +40,7 @@ fn recursive_covenant() { } fn inherit_spend(inheritor_sig: Signature) { - let inheritance_distance: Distance = 1440; - enforce_relative_distance(inheritance_distance); + enforce_relative_distance(param::INHERITANCE_DISTANCE); checksig(param::INHERITOR_PK, inheritor_sig); } diff --git a/examples/last_will/tests/last_will_test.rs b/examples/last_will/tests/last_will_test.rs index c4140367..19c01d18 100644 --- a/examples/last_will/tests/last_will_test.rs +++ b/examples/last_will/tests/last_will_test.rs @@ -9,12 +9,11 @@ use last_will_example::artifacts::last_will::derived_last_will::{LastWillArgumen use simplex::constants::DUMMY_SIGNATURE; use simplex::either::Either; -use simplex::program::{ProgramError, ProgramTrait, WitnessTrait}; -use simplex::provider::SimplicityNetwork; -use simplex::signer::{Signer, SignerError, SignerTrait}; -use simplex::simplicityhl::elements::{Script, Sequence}; +use simplex::provider::{ProviderError, SimplicityNetwork}; +use simplex::signer::{Signer, SignerError}; +use simplex::simplicityhl::elements::{Script, Sequence, Txid}; use simplex::transaction::{ - ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, SigMessage, + ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, }; // The three roles each get their own wallet, built from its own BIP-39 @@ -30,12 +29,12 @@ const COLD_MNEMONIC: &str = "letter advice cage absurd amount doctor acoustic avoid letter advice cage above"; const HOT_MNEMONIC: &str = "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"; -// Must match `inheritance_distance` in `simf/last_will.simf`. +// Passed to the contract as `param::INHERITANCE_DISTANCE`, so this is the only +// place the value is defined. // // The original contract uses 25920 blocks, about 18 days at Liquid's -// 1-minute block time. This demo uses 1440 (1 day) instead to keep the -// mining cheap: mining tens of thousands of blocks on regtest may risk a -// `nextest` timeout. +// 1-minute block time. This demo uses 1440 (1 day) to keep the mining cheap; +// mining tens of thousands of blocks on regtest risks a `nextest` timeout. const INHERITANCE_DISTANCE: u16 = 1440; // Every test funds a fresh covenant instance with the same amount, and the @@ -43,39 +42,58 @@ const INHERITANCE_DISTANCE: u16 = 1440; const FUNDING_AMOUNT: u64 = 100_000; /// The inheritor, the cold key holder and the hot key holder, as three -/// independent Simplex wallets. +/// independent Simplex wallets, together with the covenant they are bound to. struct Parties { inheritor: Signer, cold: Signer, hot: Signer, + arguments: LastWillArguments, + program: LastWillProgram, } impl Parties { fn new(context: &simplex::TestContext) -> Self { + let inheritor = context.create_signer(INHERITOR_MNEMONIC); + let cold = context.create_signer(COLD_MNEMONIC); + let hot = context.create_signer(HOT_MNEMONIC); + + // Parameters are compiled into the program, so these four values + // determine the covenant's script, and therefore its address. + let arguments = LastWillArguments { + inheritance_distance: INHERITANCE_DISTANCE, + inheritor_pk: inheritor.get_schnorr_public_key().serialize(), + cold_pk: cold.get_schnorr_public_key().serialize(), + hot_pk: hot.get_schnorr_public_key().serialize(), + }; + Self { - inheritor: context.create_signer(INHERITOR_MNEMONIC), - cold: context.create_signer(COLD_MNEMONIC), - hot: context.create_signer(HOT_MNEMONIC), + inheritor, + cold, + hot, + program: LastWillProgram::new(&arguments), + arguments, } } - /// The contract's three `param::` public keys. Because the parameters are - /// baked into the compiled program, these three wallets determine the - /// covenant's script, and therefore its address. - fn arguments(&self) -> LastWillArguments { - LastWillArguments { - inheritor_pk: self.inheritor.get_schnorr_public_key().serialize(), - cold_pk: self.cold.get_schnorr_public_key().serialize(), - hot_pk: self.hot.get_schnorr_public_key().serialize(), - } + /// The covenant these three wallets are bound to. `Program` caches its + /// compilation, so holding one instance here compiles the contract once per + /// test rather than once per spend. + fn program(&self) -> &LastWillProgram { + &self.program } - fn program(&self) -> LastWillProgram { - LastWillProgram::new(&self.arguments()) + fn script(&self, context: &simplex::TestContext) -> Script { + self.program.get_script_pubkey(context.get_network()) } - fn script(&self, context: &simplex::TestContext) -> Script { - self.program().get_script_pubkey(context.get_network()) + /// The same contract and the same three parties, but with a doubled + /// inheritance distance. Changing a `param::` changes the script, so this + /// is a valid last will covenant sitting at a different address. + fn other_covenant_script(&self, context: &simplex::TestContext) -> Script { + let mut arguments = self.arguments.clone(); + arguments.inheritance_distance = INHERITANCE_DISTANCE * 2; + + LastWillProgram::new(&arguments).get_script_pubkey(context.get_network()) } } @@ -143,14 +161,6 @@ fn fee_output(amount: u64, network: &SimplicityNetwork) -> PartialOutput { /// Funds a fresh covenant instance, then spends it via `path`, signed by /// `signer`. /// -/// `finalize_strict` appends the `change_to` output, then an Elements fee -/// output, and sets both amounts, giving the transaction two outputs. For a -/// `SpendPath::HotSpend` those are the two outputs `recursive_covenant()` -/// requires: output 0 back into the covenant, output 1 the fee. -/// -/// `RequiredSignature::witness_with_path` names the witness and the position -/// within it that the signature goes to. -/// /// Returns the local-execution or broadcast error (if any) unexamined, so /// callers can assert either success or a specific failure result. fn spend_action( @@ -161,37 +171,80 @@ fn spend_action( sequence: Sequence, signer: &Signer, change_to: ChangeTo, -) -> anyhow::Result { +) -> anyhow::Result { let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + let utxo = fund_covenant(context, &script)?; - let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let change_to = match change_to { + ChangeTo::SpenderWallet => signer.get_address().script_pubkey(), + ChangeTo::Covenant => script, + ChangeTo::Other(other) => other, + }; + let ft = covenant_spend(prog, utxo, path, sequence, change_to); + + println!("Submitting {description}..."); + + // One call: coin selection, fee rate, the change and fee outputs, local + // execution, signing, witness satisfaction and broadcast. A test that needs + // to control those steps uses `spend_with_exact_outputs` instead. + let receipt = signer.broadcast(&ft)?; + println!(" -> accepted: {receipt}"); + Ok(receipt.txid()) +} +/// Builds a spend of `utxo` through `path`, sending the value to `change_to`. +/// +/// The transaction carries one input and no explicit outputs: `Signer` appends +/// the change output and then the Elements fee output, and works out both +/// amounts. For a `SpendPath::HotSpend` with `change_to` set to the covenant's +/// own script, those two appended outputs are what `recursive_covenant()` +/// requires: output 0 back into the covenant, output 1 the fee. +fn covenant_spend( + prog: &LastWillProgram, + utxo: UTXO, + path: SpendPath, + sequence: Sequence, + change_to: Script, +) -> FinalTransaction { let mut ft = FinalTransaction::new(); + ft.add_program_input( - PartialInput::new(utxos[0].clone()).with_sequence(sequence), + PartialInput::new(utxo).with_sequence(sequence), ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), ); - ft.add_change(ChangeOutput::new(match change_to { - ChangeTo::SpenderWallet => signer.get_address().script_pubkey(), - ChangeTo::Covenant => script, - ChangeTo::Other(other) => other, - })); + ft.add_change(ChangeOutput::new(change_to)); - println!("Submitting {description}..."); + ft +} - let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; - let (tx, _fee) = signer.finalize_strict(&ft, fee_rate)?; +/// Funds a fresh instance of the covenant at `script` and returns its UTXO, +/// once the funding transaction has confirmed. +/// +/// Without the `TxReceipt::wait` the UTXO comes back out of the mempool, which +/// works, but leaves each test depending on relay timing rather than a block. +fn fund_covenant(context: &simplex::TestContext, script: &Script) -> anyhow::Result { + let receipt = context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + let txid = receipt.txid(); - // `finalize_strict` encapsulates the sign-and-satisfy sequence. The - // individual actions involved can be broken out if necessary; see below - // in spend_with_exact_outputs() for an example. - let receipt = context.get_default_provider().broadcast_transaction(&tx)?; - println!(" -> accepted: {receipt}"); - Ok(receipt.to_string()) + receipt.wait()?; + + covenant_utxo(context, script, txid) +} + +/// The UTXO that transaction `txid` created at `script`. +/// +/// Selecting by txid avoids picking up whichever other UTXO the covenant's +/// script happens to hold. +fn covenant_utxo(context: &simplex::TestContext, script: &Script, txid: Txid) -> anyhow::Result { + context + .get_default_provider() + .fetch_scripthash_utxos(script)? + .into_iter() + .find(|utxo| utxo.outpoint.txid == txid) + .ok_or_else(|| anyhow::anyhow!("transaction {txid} produced no UTXO at the covenant script")) } /// Spends a covenant UTXO with a transaction whose outputs are given exactly @@ -200,16 +253,13 @@ fn spend_action( /// Every caller is a test that expects the spend to be rejected. Spends that /// are meant to succeed go through `spend_action`. /// -/// The output-shape tests below build transactions that violate -/// `recursive_covenant()`: one output instead of two, a non-fee output at -/// index 1, and the fee and continuation outputs swapped. `spend_action` -/// cannot express those, because -/// `finalize_strict` always appends its own change and fee outputs and takes -/// the amounts from what is left over, so these transactions are balanced by +/// The output-shape tests below violate `recursive_covenant()` on purpose: one +/// output instead of two, a non-fee output at index 1, and the fee and +/// continuation outputs swapped. `spend_action` cannot express those, because +/// `Signer::broadcast` would append its own change and fee outputs on top and +/// trip the `num_outputs() == 2` check instead of the one under test. +/// `Signer::sign_tx` appends nothing, so these transactions are balanced by /// hand. -/// -/// `SignerTrait::sign_program` signs the input, and `LastWillWitness` carries -/// the signature into the right branch. fn spend_with_exact_outputs( context: &simplex::TestContext, parties: &Parties, @@ -218,20 +268,17 @@ fn spend_with_exact_outputs( sequence: Sequence, signer: &Signer, outputs: Vec, -) -> anyhow::Result { +) -> anyhow::Result { let prog = parties.program(); - let network = context.get_network(); - let script = prog.get_script_pubkey(network); - - context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + let script = prog.get_script_pubkey(context.get_network()); - let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; + let utxo = fund_covenant(context, &script)?; let mut ft = FinalTransaction::new(); ft.add_program_input( - PartialInput::new(utxos[0].clone()).with_sequence(sequence), + PartialInput::new(utxo).with_sequence(sequence), ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::None, + RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), ); for output in outputs { ft.add_output(output); @@ -239,31 +286,14 @@ fn spend_with_exact_outputs( println!("Submitting {description}..."); - // Signing and local execution are kept apart from broadcast deliberately: - // `ProgramTrait::finalize` runs the Simplicity program locally, which is - // where a failed `assert!` surfaces, before the transaction reaches the - // network; only then is it broadcast, where consensus rules such as - // relative locktimes apply. Several tests depend on telling those apart. - let (mut pst, _secrets) = ft.extract_pst(); - - let signature = signer.sign_program(&pst, prog.as_ref(), 0, network, None, &SigMessage::Sighash)?; - let witness = path.witness_with(signature.serialize()).build_witness(); - - let pruned_witness = prog.as_ref().finalize(&pst, &witness, 0, network)?; - pst.inputs_mut()[0].final_script_witness = Some(pruned_witness); - - let tx = pst.extract_tx()?; + // Signing and broadcast are kept apart deliberately: `sign_tx` runs the + // Simplicity program, where a failed `assert!` surfaces, before the + // transaction reaches the network and its consensus rules, such as relative + // locktimes. Several tests depend on telling those two apart. + let tx = signer.sign_tx(&ft)?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; println!(" -> accepted: {receipt}"); - Ok(receipt.to_string()) -} - -/// Whether a `ProgramError` represents the Simplicity program rejecting the -/// spend, as opposed to any other failure. An `assert!` that fails shows up -/// while the Bit Machine runs, which is either during execution proper or -/// during the pruning pass that `finalize` performs. -fn is_program_execution_failure(error: &ProgramError) -> bool { - matches!(error, ProgramError::Execution(_) | ProgramError::Pruning(_)) + Ok(receipt.txid()) } /// Asserts that a spend helper's result failed during local Simplicity @@ -271,24 +301,19 @@ fn is_program_execution_failure(error: &ProgramError) -> bool { /// node. Every test that expects a spend to be rejected outright (wrong /// key, or a `recursive_covenant()` violation) fails this way: the /// contract's own logic catches it before broadcast is ever attempted. -fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> anyhow::Result<()> { +/// +/// Both spend helpers run the program through the `Signer`, so a failed +/// `assert!` always arrives as `SignerError::CovenantExecution`. Matching the +/// type rather than the message text keeps the test independent of a +/// dependency's wording. +fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> anyhow::Result<()> { match result { - Ok(receipt) => { - anyhow::bail!("expected local execution to reject this ({what_was_wrong}), but it broadcast: {receipt}") + Ok(txid) => { + anyhow::bail!("expected local execution to reject this ({what_was_wrong}), but it broadcast: {txid}") } Err(err) => { - // Match the typed error rather than message text from a transitive - // dependency. The two spending paths surface it differently: - // `finalize_strict` reports a covenant input that did not execute, - // while `ProgramTrait::finalize` returns the program error directly. - let is_execution_failure = match err.downcast_ref::() { - Some(SignerError::CovenantExecution { .. }) => true, - Some(SignerError::Program(program_error)) => is_program_execution_failure(program_error), - _ => err.downcast_ref::().is_some_and(is_program_execution_failure), - }; - anyhow::ensure!( - is_execution_failure, + matches!(err.downcast_ref::(), Some(SignerError::CovenantExecution { .. })), "expected an assertion failure ({what_was_wrong}), got a different error: {err}" ); println!(" -> rejected locally, as expected: {what_was_wrong}\n"); @@ -297,11 +322,6 @@ fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) - } } -/// OP_TRUE: a script that is definitely not this covenant. -fn unrelated_script() -> Script { - Script::from(vec![0x51]) -} - #[simplex::test] fn cold_spend_breaks_out_of_the_covenant(context: simplex::TestContext) -> anyhow::Result<()> { let parties = Parties::new(&context); @@ -388,16 +408,26 @@ fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Resul ); match result { - Ok(receipt) => { - anyhow::bail!("expected the node to reject this broadcast, but it succeeded: {receipt}") + Ok(txid) => { + anyhow::bail!("expected the node to reject this broadcast, but it succeeded: {txid}") } Err(err) => { - let msg = err.to_string(); + // `ProviderError::BroadcastRejected` carries the node's own response + // body. Matching that variant, rather than grepping the formatted + // error chain, keeps this test specific to a network rejection: a + // local `assert!` failure arrives as + // `SignerError::CovenantExecution` and must not pass here. + let Some(SignerError::Provider(ProviderError::BroadcastRejected { message, .. })) = + err.downcast_ref::() + else { + anyhow::bail!("expected the node to reject this broadcast, got a different error: {err}") + }; + anyhow::ensure!( - msg.contains("non-BIP68-final"), - "expected a `non-BIP68-final` rejection specifically, got: {msg}" + message.contains("non-BIP68-final"), + "expected a `non-BIP68-final` rejection specifically, got: {message}" ); - println!(" -> rejected by the node: {msg}\n"); + println!(" -> rejected by the node: {message}\n"); println!( "The `non-BIP68-final` message is triggered by the node's mempool policy,\n\ showing how the timelock guarantee is enforced by the network.\n" @@ -423,28 +453,21 @@ fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result< let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + + // The relative timelock runs from the height the funding output confirmed + // at, so the target below has to be measured from a tip that contains it. + let utxo = fund_covenant(&context, &script)?; let target = context.get_default_provider().fetch_tip_height()? as u64 + u64::from(INHERITANCE_DISTANCE); context.get_network_utils().mine_until_height(target)?; println!("Mined up to height {target}.\n"); - let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let path = SpendPath::Inherit; - - let mut ft = FinalTransaction::new(); - ft.add_program_input( - PartialInput::new(utxos[0].clone()).with_sequence(Sequence::from_height(INHERITANCE_DISTANCE)), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), - ); - ft.add_change(ChangeOutput::new(parties.inheritor.get_address().script_pubkey())); + let to = parties.inheritor.get_address().script_pubkey(); + let ft = covenant_spend(prog, utxo, SpendPath::Inherit, Sequence::from_height(INHERITANCE_DISTANCE), to); println!("Submitting the same Inherit transaction as before, now that the wait is real..."); - let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; - let (tx, _fee) = parties.inheritor.finalize_strict(&ft, fee_rate)?; - let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + let receipt = parties.inheritor.broadcast(&ft)?; println!(" -> accepted: {receipt}"); println!( @@ -619,20 +642,29 @@ fn hot_spend_wrong_continuation_script_fails(context: simplex::TestContext) -> a println!( "\n=== HotSpend recreating the wrong script ===\n\n\ - Here, output 0 sends the money to a script output different from this exact\n\ - covenant. `recursive_covenant()` compares the output's script hash against this\n\ - program's own script hash directly (not by trusting the witness), so a HotSpend\n\ - can't be used to smuggle the funds anywhere else while disguised as a routine\n\ - refresh.\n" + Output 0 here pays to a real last will covenant over the same three keys,\n\ + differing only in its inheritance distance. Parameters are compiled into\n\ + the program, so that alone changes the script. `recursive_covenant()`\n\ + compares the output's script hash against this program's own (not trusting\n\ + the witness), so a HotSpend can't move the funds into a covenant on\n\ + different terms while disguised as a routine refresh.\n" + ); + + // Check the test's premise: a different parameter means a different address. + let other = parties.other_covenant_script(&context); + anyhow::ensure!( + other != parties.script(&context), + "changing `inheritance_distance` did not change the covenant's script" ); + let result = spend_action( &context, &parties, - "HotSpend transaction recreating an unrelated script, not this covenant", + "HotSpend transaction recreating a covenant on different terms, not this one", SpendPath::HotSpend, Sequence::default(), &parties.hot, - ChangeTo::Other(unrelated_script()), + ChangeTo::Other(other), ); expect_assert_failure(result, "HotSpend output 0 recreates the wrong script") } @@ -648,6 +680,7 @@ fn hot_spend_non_fee_second_output_fails(context: simplex::TestContext) -> anyho output, so `jet::output_is_fee(1)` is `false` and the assertion fails.\n" ); let script = parties.script(&context); + let not_a_fee = parties.hot.get_address().script_pubkey(); let result = spend_with_exact_outputs( &context, &parties, @@ -657,7 +690,7 @@ fn hot_spend_non_fee_second_output_fails(context: simplex::TestContext) -> anyho &parties.hot, vec![ PartialOutput::new(script, 99_000, context.get_network().policy_asset()), - PartialOutput::new(unrelated_script(), 1_000, context.get_network().policy_asset()), + PartialOutput::new(not_a_fee, 1_000, context.get_network().policy_asset()), ], ); expect_assert_failure(result, "HotSpend output 1 isn't a real fee output") @@ -710,58 +743,42 @@ fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext let prog = parties.program(); let script = prog.get_script_pubkey(context.get_network()); - context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; + let funded = fund_covenant(&context, &script)?; - // Returns what is left in the covenant after the hop, since Simplex works - // out the fee rather than the test fixing it in advance. - let hot_spend_hop = |amount_in: u64, hop_name: &str| -> anyhow::Result { - let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let path = SpendPath::HotSpend; + // Each hop takes the covenant UTXO it is spending and returns the fresh one + // it created, so the chain is threaded by outpoint rather than by refetching + // the script. The new balance comes from `UTXO::amount` on that output, so + // the hop never needs to know the fee. + let hot_spend_hop = |utxo: UTXO, hop_name: &str| -> anyhow::Result { + println!("Submitting {hop_name} (HotSpend, {} sats in)...", utxo.amount()); - let mut ft = FinalTransaction::new(); - ft.add_program_input( - PartialInput::new(utxos[0].clone()), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), - ); // Paying the change back into the covenant's own script gives - // `recursive_covenant()` the outputs it requires: Simplex emits that - // output first and the fee output second, and works out both amounts. - ft.add_change(ChangeOutput::new(script.clone())); + // `recursive_covenant()` the outputs it requires. + let ft = covenant_spend(prog, utxo, SpendPath::HotSpend, Sequence::default(), script.clone()); - println!("Submitting {hop_name} (HotSpend, {amount_in} sats in)..."); + let receipt = parties.hot.broadcast(&ft)?; + let txid = receipt.txid(); - let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; - let (tx, fee) = parties.hot.finalize_strict(&ft, fee_rate)?; - let receipt = context.get_default_provider().broadcast_transaction(&tx)?; // Each hop must confirm before the next one can spend its output. receipt.wait()?; - println!(" -> accepted and confirmed: {receipt} ({fee} sats fee)\n"); - Ok(amount_in - fee) + println!(" -> accepted and confirmed: {receipt}\n"); + + covenant_utxo(&context, &script, txid) }; - let after_first = hot_spend_hop(FUNDING_AMOUNT, "first refresh")?; - println!("Covenant still alive at {after_first} sats.\n"); + let after_first = hot_spend_hop(funded, "first refresh")?; + println!("Covenant still alive at {} sats.\n", after_first.amount()); let after_second = hot_spend_hop(after_first, "second refresh")?; - println!("Covenant still alive at {after_second} sats.\n"); + println!("Covenant still alive at {} sats.\n", after_second.amount()); - // Final hop: break out via ColdSpend, spending the real UTXO the second refresh + // Final hop: break out via ColdSpend, spending the UTXO the second refresh // created. - let utxos = context.get_default_provider().fetch_scripthash_utxos(&script)?; - let path = SpendPath::ColdSpend; - let mut ft = FinalTransaction::new(); - ft.add_program_input( - PartialInput::new(utxos[0].clone()), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), - ); - ft.add_change(ChangeOutput::new(parties.cold.get_address().script_pubkey())); + let to = parties.cold.get_address().script_pubkey(); + let ft = covenant_spend(prog, after_second, SpendPath::ColdSpend, Sequence::default(), to); println!("Submitting final hop (ColdSpend, breaking out of the covenant for good)..."); - let fee_rate = context.get_default_provider().fetch_fee_rate(1)?; - let (tx, _fee) = parties.cold.finalize_strict(&ft, fee_rate)?; - let receipt = context.get_default_provider().broadcast_transaction(&tx)?; + let receipt = parties.cold.broadcast(&ft)?; println!(" -> accepted: {receipt}\n"); println!( From 0dd5fc9745c6069400e8c40b11d28e0a6ac5830a Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 10 Oct 2026 22:42:51 -0700 Subject: [PATCH 4/6] Update last_will demo for the Simplex 0.2.0 API - Program inputs take an Option, so the spend path builds ProgramSignature::new("INHERIT_OR_NOT").at_path(..) in place of RequiredSignature::witness_with_path. - A program that rejects a spend now surfaces as SignerError::Program(ProgramError::ProgramExecution { .. }), which the local-rejection tests match on. - UTXO::amount() returns a Result, so the lifecycle test propagates it. - Signed transactions are extracted with .extract_tx(). Co-Authored-By: Claude Opus 5.5 --- examples/last_will/README.md | 2 +- examples/last_will/tests/last_will_test.rs | 37 ++++++++++++---------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/examples/last_will/README.md b/examples/last_will/README.md index eea474d9..13502d40 100644 --- a/examples/last_will/README.md +++ b/examples/last_will/README.md @@ -52,7 +52,7 @@ which the contract rejects. Spending uses Simplex's signing path: the witness carries a placeholder signature at the branch being taken, -`RequiredSignature::witness_with_path` says where the signature belongs, +`ProgramSignature::at_path` says where the signature belongs, and the signer fills it in. The mnemonics in the test file are standard BIP-39 test vectors, so diff --git a/examples/last_will/tests/last_will_test.rs b/examples/last_will/tests/last_will_test.rs index 19c01d18..aec951cb 100644 --- a/examples/last_will/tests/last_will_test.rs +++ b/examples/last_will/tests/last_will_test.rs @@ -9,11 +9,12 @@ use last_will_example::artifacts::last_will::derived_last_will::{LastWillArgumen use simplex::constants::DUMMY_SIGNATURE; use simplex::either::Either; +use simplex::program::ProgramError; use simplex::provider::{ProviderError, SimplicityNetwork}; use simplex::signer::{Signer, SignerError}; use simplex::simplicityhl::elements::{Script, Sequence, Txid}; use simplex::transaction::{ - ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, UTXO, + ChangeOutput, FinalTransaction, PartialInput, PartialOutput, ProgramInput, ProgramSignature, UTXO, }; // The three roles each get their own wallet, built from its own BIP-39 @@ -102,7 +103,7 @@ impl Parties { /// `witness::INHERIT_OR_NOT` is an `Either>`, so each spending path is a position in that nested type. /// `witness()` puts a placeholder signature at that position, and -/// `sig_path()` names the same position for the signer, which replaces it. +/// `signature()` names the same position for the signer, which replaces it. /// An `enum` would read better in the contract, but Simplex does not support /// contracts that use one. #[derive(Clone, Copy)] @@ -127,12 +128,13 @@ impl SpendPath { } } - fn sig_path(self) -> &'static [&'static str] { - match self { + fn signature(self) -> ProgramSignature { + let path: &[&str] = match self { SpendPath::Inherit => &["Left"], SpendPath::ColdSpend => &["Right", "Left"], SpendPath::HotSpend => &["Right", "Right"], - } + }; + ProgramSignature::new("INHERIT_OR_NOT").at_path(path) } } @@ -212,8 +214,8 @@ fn covenant_spend( ft.add_program_input( PartialInput::new(utxo).with_sequence(sequence), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), + ProgramInput::new(Box::new(prog.as_ref().clone()), path.witness()), + Some(path.signature()), ); ft.add_change(ChangeOutput::new(change_to)); @@ -277,8 +279,8 @@ fn spend_with_exact_outputs( let mut ft = FinalTransaction::new(); ft.add_program_input( PartialInput::new(utxo).with_sequence(sequence), - ProgramInput::new(Box::new(prog.as_ref().clone()), Box::new(path.witness())), - RequiredSignature::witness_with_path("INHERIT_OR_NOT", path.sig_path()), + ProgramInput::new(Box::new(prog.as_ref().clone()), path.witness()), + Some(path.signature()), ); for output in outputs { ft.add_output(output); @@ -290,7 +292,7 @@ fn spend_with_exact_outputs( // Simplicity program, where a failed `assert!` surfaces, before the // transaction reaches the network and its consensus rules, such as relative // locktimes. Several tests depend on telling those two apart. - let tx = signer.sign_tx(&ft)?; + let tx = signer.sign_tx(&ft)?.extract_tx()?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; println!(" -> accepted: {receipt}"); Ok(receipt.txid()) @@ -303,7 +305,7 @@ fn spend_with_exact_outputs( /// contract's own logic catches it before broadcast is ever attempted. /// /// Both spend helpers run the program through the `Signer`, so a failed -/// `assert!` always arrives as `SignerError::CovenantExecution`. Matching the +/// `assert!` always arrives as `ProgramError::ProgramExecution`. Matching the /// type rather than the message text keeps the test independent of a /// dependency's wording. fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> anyhow::Result<()> { @@ -313,7 +315,10 @@ fn expect_assert_failure(result: anyhow::Result, what_was_wrong: &str) -> } Err(err) => { anyhow::ensure!( - matches!(err.downcast_ref::(), Some(SignerError::CovenantExecution { .. })), + matches!( + err.downcast_ref::(), + Some(SignerError::Program(ProgramError::ProgramExecution { .. })) + ), "expected an assertion failure ({what_was_wrong}), got a different error: {err}" ); println!(" -> rejected locally, as expected: {what_was_wrong}\n"); @@ -416,7 +421,7 @@ fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Resul // body. Matching that variant, rather than grepping the formatted // error chain, keeps this test specific to a network rejection: a // local `assert!` failure arrives as - // `SignerError::CovenantExecution` and must not pass here. + // `ProgramError::ProgramExecution` and must not pass here. let Some(SignerError::Provider(ProviderError::BroadcastRejected { message, .. })) = err.downcast_ref::() else { @@ -750,7 +755,7 @@ fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext // the script. The new balance comes from `UTXO::amount` on that output, so // the hop never needs to know the fee. let hot_spend_hop = |utxo: UTXO, hop_name: &str| -> anyhow::Result { - println!("Submitting {hop_name} (HotSpend, {} sats in)...", utxo.amount()); + println!("Submitting {hop_name} (HotSpend, {} sats in)...", utxo.amount()?); // Paying the change back into the covenant's own script gives // `recursive_covenant()` the outputs it requires. @@ -767,9 +772,9 @@ fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext }; let after_first = hot_spend_hop(funded, "first refresh")?; - println!("Covenant still alive at {} sats.\n", after_first.amount()); + println!("Covenant still alive at {} sats.\n", after_first.amount()?); let after_second = hot_spend_hop(after_first, "second refresh")?; - println!("Covenant still alive at {} sats.\n", after_second.amount()); + println!("Covenant still alive at {} sats.\n", after_second.amount()?); // Final hop: break out via ColdSpend, spending the UTXO the second refresh // created. From bc4982a5392723ee0b3e8be452deed222dbbc809 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 5 Nov 2026 14:15:01 -0800 Subject: [PATCH 5/6] Add a Cargo.lock for the last_will example Derived from examples/basic/Cargo.lock, so the example resolves to the same dependency versions as the rest of the workspace, including the pinned proptest 1.12.0. Only the root package entry differs. Co-Authored-By: Claude Opus 5.5 --- examples/last_will/Cargo.lock | 2037 +++++++++++++++++++++++++++++++++ 1 file changed, 2037 insertions(+) create mode 100644 examples/last_will/Cargo.lock diff --git a/examples/last_will/Cargo.lock b/examples/last_will/Cargo.lock new file mode 100644 index 00000000..d6f453f7 --- /dev/null +++ b/examples/last_will/Cargo.lock @@ -0,0 +1,2037 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "ariadne" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31beedec3ce83ae6da3a79592b3d8d7afd146a5b15bb9bb940279aced60faa89" +dependencies = [ + "unicode-width", + "yansi", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base58ck" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "365c0acd5b2e8dd0111a46c4faea83fb3cfb6e39a49a7c73a06e090db7b2eff0" +dependencies = [ + "bitcoin_hashes 0.14.101", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4559bb9225f8401dcea601cf8dc9ed81ea0dadd9cf7755fdefdd93ef7961e19e" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.8", + "rand_core 0.6.4", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + +[[package]] +name = "bitcoin" +version = "0.32.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0ce8bd5baaa0d303a19915a6d93afed161f528654e42da2a7a97d05c59499a" +dependencies = [ + "base58ck", + "base64 0.21.7", + "bech32", + "bitcoin-consensus-encoding", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes 0.14.101", + "hex-conservative 0.2.3", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" +dependencies = [ + "bitcoin-internals 0.7.0", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin-private" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73290177011694f38ec25e165d0387ab7ea749a4b81cd4c80dae5988229f7a57" + +[[package]] +name = "bitcoin-units" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb95693f371d089a4b5b6fc41c6f3ea6e01ee8c15388335dfac8ea685173b51" +dependencies = [ + "bitcoin-consensus-encoding", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.3", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoincore-rpc" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee" +dependencies = [ + "bitcoincore-rpc-json", + "jsonrpc", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoincore-rpc-json" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a" +dependencies = [ + "bitcoin", + "serde", + "serde_json", +] + +[[package]] +name = "bitcoind" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce6620b7c942dbe28cc49c21d95e792feb9ffd95a093205e7875ccfa69c2925" +dependencies = [ + "anyhow", + "bitcoincore-rpc", + "log", + "tempfile", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chumsky" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc17a6284abccac6e50db35c1cee87f605474a72939b959a3a67d9371800efd" +dependencies = [ + "hashbrown 0.15.5", + "regex-automata 0.3.9", + "serde", + "stacker", + "unicode-ident", + "unicode-segmentation", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "electrsd" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91435161fb2ad5098e7ac7a4b793bf9c34723b0208a3fcf6f33707489e771396" +dependencies = [ + "bitcoind", + "electrum-client", + "log", + "nix", + "which", +] + +[[package]] +name = "electrum-client" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0bd443023f9f5c4b7153053721939accc7113cbdf810a024434eed454b3db1" +dependencies = [ + "bitcoin", + "log", + "serde", + "serde_json", +] + +[[package]] +name = "elements" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e00f7ddcd826d2f4c9bd72d79a6b6991a0ac4286e7e2b2ff8eecd130549073" +dependencies = [ + "bech32", + "bitcoin", + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "bitcoin_hashes 1.2.0", + "hex-conservative 1.3.0", + "secp256k1-zkp", + "serde", + "serde_json", +] + +[[package]] +name = "elements-miniscript" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c18e931c81e6e7e58d63b6fea1c57f5534717f0e6721741c4fce72a6c547e19" +dependencies = [ + "bitcoin", + "elements", + "miniscript", + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + +[[package]] +name = "ghost-cell" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8449d342b1c67f49169e92e71deb7b9b27f30062301a16dbc27a4cc8d2351b7" + +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.18", + "regex-syntax 0.8.11", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.13.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "ignore" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.18", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonrpc" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +dependencies = [ + "base64 0.13.1", + "minreq 2.14.1", + "serde", + "serde_json", +] + +[[package]] +name = "last_will_example" +version = "0.1.0" +dependencies = [ + "anyhow", + "bitcoin_hashes 0.14.101", + "smplx-std", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniscript" +version = "12.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06a2f93613888f7d069f00cb94f83dab82f01f78a50c9935814a2547fa30ceba" +dependencies = [ + "bech32", + "bitcoin", +] + +[[package]] +name = "minreq" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "minreq" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659579df697b372ef9e36f02fcbb41f6d6f157dcec7db9c9618fa0f23cf0fc20" +dependencies = [ + "rustls", + "rustls-webpki", + "serde", + "serde_json", + "webpki-roots", +] + +[[package]] +name = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "chacha20", + "core_detect", + "num-traits", + "rand 0.10.3", + "rand_xorshift", + "regex-syntax 0.8.11", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex-automata" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.11", +] + +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.8", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-zkp" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f6ab6ef5189ded7d11cb384a96a9f871001e50b391a8fcbb310c9ebc2099b" +dependencies = [ + "bitcoin-private", + "rand 0.8.8", + "secp256k1", + "secp256k1-zkp-sys", + "serde", +] + +[[package]] +name = "secp256k1-zkp-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f08b2d0b143a22e07f798ae4f0ab20d5590d7c68e0d090f2088a48a21d1654" +dependencies = [ + "cc", + "secp256k1-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simplicity-lang" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff7b10251528d46d9cf60238267911984fedbb0948cccea98dd74fc6bf9f729" +dependencies = [ + "bitcoin", + "bitcoin_hashes 1.2.0", + "byteorder", + "elements", + "getrandom 0.2.17", + "ghost-cell", + "hex-conservative 1.3.0", + "miniscript", + "simplicity-sys", +] + +[[package]] +name = "simplicity-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "955935733b2735665b4735d9b1361eade6be4c7aea6bca2640345e4c81756f58" +dependencies = [ + "bitcoin_hashes 1.2.0", + "cc", +] + +[[package]] +name = "simplicityhl" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850c93fc9906e3324a299f50912d6176cdb381d31959362a7dd116fd2d0679a3" +dependencies = [ + "ariadne", + "base64 0.21.7", + "chumsky", + "clap", + "either", + "getrandom 0.2.17", + "itertools", + "miniscript", + "semver", + "serde", + "serde_json", + "simplicity-lang", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smplx-build" +version = "0.2.0" +dependencies = [ + "globwalk", + "pathdiff", + "prettyplease", + "proc-macro2", + "quote", + "serde", + "serde_json", + "simplicityhl", + "syn 3.0.9", + "thiserror", + "toml", + "toml_edit", +] + +[[package]] +name = "smplx-macros" +version = "0.2.0" +dependencies = [ + "smplx-build", + "smplx-test", + "syn 3.0.9", +] + +[[package]] +name = "smplx-regtest" +version = "0.2.0" +dependencies = [ + "electrsd", + "hex", + "hmac", + "serde", + "sha2", + "smplx-sdk", + "thiserror", + "toml", +] + +[[package]] +name = "smplx-sdk" +version = "0.2.0" +dependencies = [ + "bip39", + "bitcoincore-rpc", + "dyn-clone", + "elements-miniscript", + "hex", + "minreq 3.0.0", + "serde", + "serde_json", + "simplicityhl", + "thiserror", +] + +[[package]] +name = "smplx-std" +version = "0.2.0" +dependencies = [ + "either", + "rand 0.10.3", + "rand_core 0.10.1", + "serde", + "simplicityhl", + "smplx-macros", + "smplx-sdk", + "smplx-test", +] + +[[package]] +name = "smplx-test" +version = "0.2.0" +dependencies = [ + "electrsd", + "proc-macro2", + "proptest", + "quote", + "rand 0.10.3", + "serde", + "simplicityhl", + "smplx-regtest", + "smplx-sdk", + "syn 3.0.9", + "thiserror", + "toml", +] + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.6", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" From de023e26a30c814d78d1d4a470e8507d1fb82b79 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 12 Nov 2026 13:16:54 -0800 Subject: [PATCH 6/6] Apply some style guide improvements to text throughout the example --- examples/last_will/README.md | 51 +++++++-------- examples/last_will/simf/last_will.simf | 11 ++-- examples/last_will/simf/timelocks.simf | 2 +- examples/last_will/tests/last_will_test.rs | 76 ++++++++++------------ 4 files changed, 63 insertions(+), 77 deletions(-) diff --git a/examples/last_will/README.md b/examples/last_will/README.md index 13502d40..7da8c7c6 100644 --- a/examples/last_will/README.md +++ b/examples/last_will/README.md @@ -3,10 +3,8 @@ This example builds on [`examples/basic`](../basic). Where `p2pk.simf` there is a single spending condition, `last_will.simf` is a recursive covenant: it re-creates itself on-chain across a series of transactions, -and offers three spending actions depending on which key signs. - -When its tests are run with `-v`, this example adds narration to give -more context about the actions each test is performing. +and offers three spending actions, each requiring a signature from a +specific key. ## Overview @@ -25,15 +23,14 @@ arguments: the inheritance clock. `refresh_spend` checks a signature and then calls `recursive_covenant()`, which requires the transaction to pay back into the same covenant script (output 0) alongside an Elements fee output - (output 1). Bitcoin Script has no equivalent, because it cannot inspect - a transaction's outputs. + (output 1). -For Simplex compatibility, these actions are represented here as Either -types (instantiated with Left/Right) rather than named `enum` values. +For Simplex compatibility, these actions are represented here as `Either` +types (instantiated with `Left`/`Right`) rather than named `enum` values. ### Keys -The three keys are ordinary Simplex wallets. `tests/last_will_test.rs` +Each key comes from its own Simplex wallet. `tests/last_will_test.rs` builds one per party with `TestContext::create_signer`, each from its own BIP-39 mnemonic, the same way `Simplex.toml`'s `[test] mnemonic` produces the default test wallet: @@ -50,10 +47,9 @@ address, is derived from all four; a covenant with the same three keys but a doubled inheritance distance, which the contract rejects. -Spending uses Simplex's signing path: the witness carries a placeholder -signature at the branch being taken, -`ProgramSignature::at_path` says where the signature belongs, -and the signer fills it in. +The witness holds a placeholder signature at the branch being taken, +`ProgramSignature::at_path` names that position, and the signer fills it +in. The mnemonics in the test file are standard BIP-39 test vectors, so their keys are public. Generate your own secrets for any deployment @@ -65,28 +61,28 @@ The tests give Simplex the input and a change target. Simplex appends the change output, then an Elements fee output, and works out both amounts: ```rust -ft.add_change(ChangeOutput::new(script.clone())); +ft.add_change(ChangeOutput::new(change_to)); ``` For a **HotSpend** the change target is the covenant's own script, which produces the two outputs `recursive_covenant()` requires: output 0 paying back into the same covenant, output 1 the fee. For **ColdSpend** and -**Inherit** the contract places no constraints on outputs, so the change -target is the spending party's wallet. +**Inherit** the contract doesn't constrain outputs, so the change target is +the spending party's wallet. Three tests check that the covenant rejects a malformed output set: one with a single output, one with a non-fee output at index 1, and one with -the fee and continuation outputs swapped. A change target cannot produce -those shapes, because Simplex always appends change first and fee second, -so those tests list their outputs directly and sign with `Signer::sign_tx`, -which appends nothing. +the fee and continuation outputs swapped. A change target can't produce +those shapes, because Simplex appends any change output before the fee +output. Those tests list their outputs directly and sign with +`Signer::sign_tx`, which doesn't append any outputs. The repository structure is the same shape as `examples/basic`: ```md root ├── simf — Simplicity smart contracts source directory -├── src — The project's Rust code (witness and transaction builders) +├── src — Declares the generated artifacts module │ └── artifacts — Simplicity artifacts generated by Simplex (gitignored) ├── tests — Simplex tests directory ├── Cargo.toml — The project's Rust configuration @@ -108,14 +104,13 @@ as a module in `src/lib.rs`. simplex test -v ``` -The `-v` option adds verbose output. The test suite includes narration, -with each test showing what it's attempting and how the network -responded. Several tests expect a rejection (wrong key, broken +The `-v` option shows the test suite's narration: each test prints what +it's attempting and whether local execution or the node accepted it. +Several tests expect a rejection (wrong key, broken `recursive_covenant()` check, or inheritance claimed too early). -One test, `inherit_after_wait_succeeds`, synthetically mines 1440 blocks -to make the inheritance wait real. This should be relatively rapid with -local `elementsd`. +One test, `inherit_after_wait_succeeds`, mines the required blocks on the +local regtest so that the inheritance wait has elapsed. ### Low-level tracing @@ -130,6 +125,6 @@ This shows the individual [jet](https://docs.simplicity-lang.org/documentation/jets) calls during the course of the contract's execution, with their input and output values. You can use this information to follow the contract's own logic -during a real transaction validation. For example, in the test cases that +during transaction validation. For example, in the test cases that exercise invalid spend conditions, you can see where the failures and rejections happened. diff --git a/examples/last_will/simf/last_will.simf b/examples/last_will/simf/last_will.simf index e8a46315..75f4ba1d 100644 --- a/examples/last_will/simf/last_will.simf +++ b/examples/last_will/simf/last_will.simf @@ -3,8 +3,8 @@ * * The inheritor can spend the coins if the owner doesn't move them for * `param::INHERITANCE_DISTANCE` blocks. The owner has to repeat the - * covenant when he moves the coins with his hot key. The owner can break - * out of the covenant with his cold key. + * covenant when moving the coins with the hot key. The owner can break + * out of the covenant with the cold key. * * Adapted from `last_will.simf` in BlockstreamResearch/SimplicityHL. * Changes: @@ -14,11 +14,8 @@ * deployment is bound to its own keys and picks its own wait, and the * test suite can sign with ordinary Simplex wallets. * - The older Left/Right mechanism for selecting the spending action, - * instead of an `enum`, is restored because Simplex currently does - * not support contracts using `enum`. - * - The demo passes 1440 blocks (1 day on Liquid) as the inheritance - * distance instead of the original 25920, so the tests can mine the - * wait quickly. + * instead of an `enum`, is restored because Simplex currently doesn't + * support contracts using `enum`. */ use crate::timelocks::enforce_relative_distance; diff --git a/examples/last_will/simf/timelocks.simf b/examples/last_will/simf/timelocks.simf index 3ac178ad..7ee55973 100644 --- a/examples/last_will/simf/timelocks.simf +++ b/examples/last_will/simf/timelocks.simf @@ -4,7 +4,7 @@ pub fn enforce_relative_distance(min_distance: Distance) { // block containing the input UTXO. // Panic otherwise. - // This is a replacement for the deprecated jet::check_lock_distance. + // This is a replacement for jet::broken_do_not_use_check_lock_distance. // Transaction version must be at least 2. assert!(jet::le_32(2, jet::version())); diff --git a/examples/last_will/tests/last_will_test.rs b/examples/last_will/tests/last_will_test.rs index aec951cb..4d9ad0da 100644 --- a/examples/last_will/tests/last_will_test.rs +++ b/examples/last_will/tests/last_will_test.rs @@ -1,7 +1,7 @@ // Narrated Simplex demo for `last_will.simf`, a recursive covenant. // // These tests exercise various failure conditions in addition to the -// happy paths, showing how the contract logic and network consensus +// happy paths. They show how the contract logic and network consensus // enforce spending rules. use last_will_example::artifacts::last_will::LastWillProgram; @@ -42,8 +42,7 @@ const INHERITANCE_DISTANCE: u16 = 1440; // fee is worked out by Simplex rather than fixed here. const FUNDING_AMOUNT: u64 = 100_000; -/// The inheritor, the cold key holder and the hot key holder, as three -/// independent Simplex wallets, together with the covenant they are bound to. +/// The three parties and the covenant they're bound to. struct Parties { inheritor: Signer, cold: Signer, @@ -88,8 +87,8 @@ impl Parties { } /// The same contract and the same three parties, but with a doubled - /// inheritance distance. Changing a `param::` changes the script, so this - /// is a valid last will covenant sitting at a different address. + /// inheritance distance, so it's a valid last will covenant at a + /// different address. fn other_covenant_script(&self, context: &simplex::TestContext) -> Script { let mut arguments = self.arguments.clone(); arguments.inheritance_distance = INHERITANCE_DISTANCE * 2; @@ -104,7 +103,7 @@ impl Parties { /// Signature>>`, so each spending path is a position in that nested type. /// `witness()` puts a placeholder signature at that position, and /// `signature()` names the same position for the signer, which replaces it. -/// An `enum` would read better in the contract, but Simplex does not support +/// An `enum` would read better in the contract, but Simplex doesn't support /// contracts that use one. #[derive(Clone, Copy)] enum SpendPath { @@ -188,9 +187,9 @@ fn spend_action( println!("Submitting {description}..."); - // One call: coin selection, fee rate, the change and fee outputs, local - // execution, signing, witness satisfaction and broadcast. A test that needs - // to control those steps uses `spend_with_exact_outputs` instead. + // One call: coin selection, the change and fee outputs, local execution, + // signing and broadcast. A test that needs to control those steps uses + // `spend_with_exact_outputs` instead. let receipt = signer.broadcast(&ft)?; println!(" -> accepted: {receipt}"); Ok(receipt.txid()) @@ -225,8 +224,10 @@ fn covenant_spend( /// Funds a fresh instance of the covenant at `script` and returns its UTXO, /// once the funding transaction has confirmed. /// -/// Without the `TxReceipt::wait` the UTXO comes back out of the mempool, which -/// works, but leaves each test depending on relay timing rather than a block. +/// The built-in regtest mines a block on every broadcast, so there the +/// `TxReceipt::wait` returns at once. With an external Esplora provider +/// (`[test.esplora]`), it makes each test wait for a block instead of reading +/// the UTXO from the mempool. fn fund_covenant(context: &simplex::TestContext, script: &Script) -> anyhow::Result { let receipt = context.get_default_signer().send(script.clone(), FUNDING_AMOUNT)?; let txid = receipt.txid(); @@ -257,11 +258,11 @@ fn covenant_utxo(context: &simplex::TestContext, script: &Script, txid: Txid) -> /// /// The output-shape tests below violate `recursive_covenant()` on purpose: one /// output instead of two, a non-fee output at index 1, and the fee and -/// continuation outputs swapped. `spend_action` cannot express those, because +/// continuation outputs swapped. `spend_action` can't express those, because /// `Signer::broadcast` would append its own change and fee outputs on top and -/// trip the `num_outputs() == 2` check instead of the one under test. -/// `Signer::sign_tx` appends nothing, so these transactions are balanced by -/// hand. +/// fail the `num_outputs() == 2` check instead of the one under test. +/// `Signer::sign_tx` doesn't append any outputs, so these transactions are +/// balanced by hand. fn spend_with_exact_outputs( context: &simplex::TestContext, parties: &Parties, @@ -288,10 +289,6 @@ fn spend_with_exact_outputs( println!("Submitting {description}..."); - // Signing and broadcast are kept apart deliberately: `sign_tx` runs the - // Simplicity program, where a failed `assert!` surfaces, before the - // transaction reaches the network and its consensus rules, such as relative - // locktimes. Several tests depend on telling those two apart. let tx = signer.sign_tx(&ft)?.extract_tx()?; let receipt = context.get_default_provider().broadcast_transaction(&tx)?; println!(" -> accepted: {receipt}"); @@ -359,10 +356,8 @@ fn hot_spend_refreshes_the_covenant(context: simplex::TestContext) -> anyhow::Re `refresh_spend` checks a signature and then calls `recursive_covenant()`,\n\ which requires the transaction to have two outputs. Output 0 must be a new\n\ UTXO paying back to the same covenant script, verified by comparing\n\ - commitment hashes rather than by trusting the witness. Output 1 must be a\n\ - fee output. These two checks carry the covenant across transactions.\n\ - Bitcoin Script has no equivalent, because it cannot inspect or constrain\n\ - transaction outputs.\n\n\ + script hashes rather than by trusting the witness. Output 1 must be a\n\ + fee output. These two checks carry the covenant across transactions.\n\n\ The test doesn't assemble those two outputs itself: it hands Simplex the\n\ covenant's own script as the change target, and Simplex emits the change\n\ output followed by the fee output, working out both amounts.\n" @@ -379,8 +374,8 @@ fn hot_spend_refreshes_the_covenant(context: simplex::TestContext) -> anyhow::Re )?; println!( - "The covenant's {INHERITANCE_DISTANCE}-block inheritance clock has effectively\n\ - been reset. The new UTXO this transaction creates is a fresh instance of the\n\ + "The covenant's {INHERITANCE_DISTANCE}-block inheritance clock has been\n\ + reset. The new UTXO this transaction creates is a fresh instance of the\n\ same covenant, carrying the same rules as the original.\n" ); Ok(()) @@ -434,8 +429,8 @@ fn inherit_too_early_is_rejected(context: simplex::TestContext) -> anyhow::Resul ); println!(" -> rejected by the node: {message}\n"); println!( - "The `non-BIP68-final` message is triggered by the node's mempool policy,\n\ - showing how the timelock guarantee is enforced by the network.\n" + "`non-BIP68-final` means the input's relative timelock (a BIP 68\n\ + consensus rule) hasn't expired yet, so the node refuses the transaction.\n" ); } } @@ -448,12 +443,10 @@ fn inherit_after_wait_succeeds(context: simplex::TestContext) -> anyhow::Result< println!( "\n=== Inherit successfully: fast-forwarding {INHERITANCE_DISTANCE} blocks ===\n\n\ - At Liquid's 1-minute block time, {INHERITANCE_DISTANCE} blocks is about a day,\n\ - against the 25920 blocks (~18 days) the upstream contract asks for. In a\n\ - real deployment, the inheritor has to wait the correct amount of time\n\ - before inheriting. For demonstration purposes, we can fast-forward the\n\ - network by asking the regtest to mine the required number of blocks\n\ - immediately, ignoring its default blocktime delay.\n" + At Liquid's 1-minute block time, {INHERITANCE_DISTANCE} blocks is about a day.\n\ + In a real deployment, the inheritor has to wait the correct amount of time\n\ + before inheriting. For demonstration purposes, this test asks the regtest\n\ + node to mine the required number of blocks immediately.\n" ); let prog = parties.program(); @@ -539,7 +532,7 @@ fn cold_spend_with_hot_key_fails(context: simplex::TestContext) -> anyhow::Resul "\n=== ColdSpend, signed with the hot key instead of the cold key ===\n\n\ Even though the two owner-held keys (hot and cold) belong to the same person\n\ in practice, they aren't interchangeable and have different abilities.\n\ - Here we demonstrate this by trying to sign a ColdSpend with the hot key.\n" + This test signs a ColdSpend with the hot key.\n" ); let result = spend_action( &context, @@ -612,8 +605,8 @@ fn inherit_with_cold_key_after_wait_fails(context: simplex::TestContext) -> anyh } // ===================================================================== -// `recursive_covenant()` violations. Each condition enforcing the -// structure of the covenant is broken on purpose by one of these tests. +// `recursive_covenant()` violations. Each of these tests breaks one of the +// covenant's structural conditions on purpose. // ===================================================================== #[simplex::test] @@ -709,8 +702,8 @@ fn hot_spend_swapped_outputs_fails(context: simplex::TestContext) -> anyhow::Res "\n=== HotSpend with the fee and continuation outputs swapped ===\n\n\ A real continuation output and a real fee output are both present, but at the\n\ wrong indices. Both of `recursive_covenant()`'s checks are position-specific\n\ - (`output_script_hash(0)`, `output_is_fee(1)`), not \"does some output satisfy\n\ - this somewhere,\" so swapping them fails.\n" + (`output_script_hash(0)`, `output_is_fee(1)`), so swapping the outputs\n\ + fails.\n" ); let script = parties.script(&context); let result = spend_with_exact_outputs( @@ -729,8 +722,8 @@ fn hot_spend_swapped_outputs_fails(context: simplex::TestContext) -> anyhow::Res } // ===================================================================== -// A realistic multi-hop spending chain: the covenant is refreshed twice by -// the owner and then gets broken out of with ColdSpend. +// Spending chain: the owner refreshes the covenant twice, then exits with +// ColdSpend. // ===================================================================== #[simplex::test] @@ -764,7 +757,8 @@ fn covenant_survives_two_refreshes_then_breaks_out(context: simplex::TestContext let receipt = parties.hot.broadcast(&ft)?; let txid = receipt.txid(); - // Each hop must confirm before the next one can spend its output. + // Confirm each hop before the next one spends its output, as + // `fund_covenant` does. receipt.wait()?; println!(" -> accepted and confirmed: {receipt}\n");