From db99d6168b3b797354553008eda71c5bb46b69d0 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 12 Aug 2026 15:24:16 -0700 Subject: [PATCH 1/5] Add relative timelock enforcement functions --- README.md | 2 ++ simf/lib/timelocks.simf | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 simf/lib/timelocks.simf diff --git a/README.md b/README.md index e607392..c247888 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ simf/lib │ └── Basic binary logic operations: `and`, `or`, `not`, `xor`. ├── op_return.simf │ └── Utilities for detecting and enforcing `OP_RETURN` (null data) outputs. +├── timelocks.simf +│ └── Enforcement of relative timelocks (`Distance` and `Duration`). ├── u8.simf ├── u16.simf ├── u32.simf diff --git a/simf/lib/timelocks.simf b/simf/lib/timelocks.simf new file mode 100644 index 0000000..c625ce2 --- /dev/null +++ b/simf/lib/timelocks.simf @@ -0,0 +1,35 @@ +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)); +} + +pub fn enforce_relative_duration(min_duration: Duration) { + // Assert that the current input is spent in a transaction that can + // only appear a duration of at least min_duration units of 512 + // seconds after the creation of the block containing the input UTXO. + // Panic otherwise. + + // This is a replacement for the deprecated jet::check_lock_duration. + + // 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_duration: Duration = unwrap_right::(actual_data); + + assert!(jet::le_16(min_duration, actual_duration)); +} From 56464d74e321d9ca6e6145f3cce52f08cb0a96bb Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 12 Aug 2026 15:24:38 -0700 Subject: [PATCH 2/5] Add Simplex tests for timelock functions --- simf/timelocks_test.simf | 11 ++ tests/common/core.rs | 118 +++++++++++++++-- tests/timelocks_test.rs | 275 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 simf/timelocks_test.simf create mode 100644 tests/timelocks_test.rs diff --git a/simf/timelocks_test.simf b/simf/timelocks_test.simf new file mode 100644 index 0000000..2452297 --- /dev/null +++ b/simf/timelocks_test.simf @@ -0,0 +1,11 @@ +use crate::lib::timelocks::{enforce_relative_distance, enforce_relative_duration}; +use crate::helper::if_test_this_function; + +fn main() { + let fn_idx: u8 = witness::FUNCTION_INDEX; + let min_distance: Distance = witness::MIN_DISTANCE; + let min_duration: Duration = witness::MIN_DURATION; + + match if_test_this_function(0, fn_idx) { true => { enforce_relative_distance(min_distance); }, false => (), }; + match if_test_this_function(1, fn_idx) { true => { enforce_relative_duration(min_duration); }, false => (), }; +} diff --git a/tests/common/core.rs b/tests/common/core.rs index ada5e42..c316a15 100644 --- a/tests/common/core.rs +++ b/tests/common/core.rs @@ -3,7 +3,7 @@ #![allow(dead_code)] use simplex::program::{Program, WitnessTrait}; -use simplex::simplicityhl::elements::Script; +use simplex::simplicityhl::elements::{Script, Sequence}; use simplex::transaction::{ FinalTransaction, PartialInput, PartialOutput, ProgramInput, RequiredSignature, }; @@ -16,13 +16,21 @@ pub enum Expect { AssertFailed, /// Execution reached a pruned branch (e.g. `unwrap(None)`, a `safe_*` overflow). PrunedBranch, + /// Local Simplicity execution succeeds, but the node itself rejects the finished + /// transaction (e.g. a BIP68 relative-locktime declared in nSequence that hasn't + /// actually been satisfied on-chain yet). Unlike `AssertFailed`/`PrunedBranch`, which + /// fail during local execution inside `Signer::broadcast` before the transaction is + /// ever sent anywhere, this only fires once local execution has already succeeded + /// and the tx reaches the node's own mempool-acceptance checks. + BroadcastRejected, } impl Expect { - /// The exact broadcast error message for a failing expectation (`None` for `Ok`). + /// The exact broadcast error message for a local-execution failure (`None` for + /// `Ok`/`BroadcastRejected`, which are handled separately in `assert_error_msg`). fn error_message(self) -> Option<&'static str> { match self { - Expect::Ok => None, + Expect::Ok | Expect::BroadcastRejected => None, Expect::AssertFailed => Some("Failed to prune program: Jet failed during execution"), Expect::PrunedBranch => { Some("Failed to prune program: Execution reached a pruned branch") @@ -30,7 +38,6 @@ impl Expect { } } } - /// Send sats to the program's script so it has a UTXO to spend. pub fn fund( context: &simplex::TestContext, @@ -93,15 +100,32 @@ pub fn assert_error_msg( result: Result, expect: Expect, ) -> anyhow::Result<()> { - match expect.error_message() { - None => { + match expect { + Expect::Ok => { result?; } - Some(expected) => { + Expect::BroadcastRejected => { + // Confirmed against a live regtest node: elementsd (inheriting Bitcoin + // Core's mempool policy) rejects a transaction whose declared BIP68 + // relative-locktime hasn't actually been satisfied on-chain with + // `sendrawtransaction RPC error -26: non-BIP68-final`, regardless of + // whether the unmet lock was a distance or a duration. + let err = result + .expect_err("expected the node to reject the broadcast, but it succeeded") + .to_string(); + assert!( + err.contains("non-BIP68-final"), + "expected a `non-BIP68-final` rejection, got: {err}" + ); + } + Expect::AssertFailed | Expect::PrunedBranch => { + let expected = expect + .error_message() + .expect("AssertFailed/PrunedBranch always have a fixed error message"); let err = result .expect_err("expected the spend to fail, but it succeeded") .to_string(); - assert!(err.contains(expected)); + assert!(err.contains(expected), "expected `{expected}`, got: {err}"); } }; @@ -141,3 +165,81 @@ where assert_error_msg(result, expect) } + +// Add `Sequence` to the existing import: +// use simplex::simplicityhl::elements::{Script, Sequence}; + +/// Construct the funded UTXO with `witness`, spent under a caller-chosen `sequence` +/// (nSequence) instead of the default (relative-timelock-disabled) value. +pub fn construct_final_tx_with_sequence( + context: &simplex::TestContext, + program: &impl AsRef, + script: &Script, + witness: W, + sequence: Sequence, +) -> anyhow::Result +where + W: WitnessTrait + 'static, +{ + 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(program.as_ref().clone()), Box::new(witness)), + RequiredSignature::None, + ); + + Ok(ft) +} + +/// Spend the funded UTXO with `witness` under a caller-chosen `sequence`. Return the +/// broadcast result. +pub fn spend_with_sequence( + context: &simplex::TestContext, + program: &impl AsRef, + script: &Script, + witness: W, + sequence: Sequence, +) -> anyhow::Result +where + W: WitnessTrait + 'static, +{ + let ft = construct_final_tx_with_sequence(context, program, script, witness, sequence)?; + + Ok(context.get_default_signer().broadcast(&ft)?.to_string()) +} + +/// Fund + spend + assert the outcome, using a custom relative-locktime `sequence`. +/// +/// `blocks_to_mine` mines that many *additional* blocks (beyond the 1 confirmation the +/// funding tx already has) before broadcasting, so a real BIP68 relative-locktime +/// requirement encoded in `sequence` is genuinely satisfied on-chain. It matters only for +/// `Expect::Ok` cases: `Expect::AssertFailed`/`Expect::PrunedBranch` cases fail inside +/// local Simplicity execution during `Signer::broadcast`'s witness-finalization step, +/// before the transaction is ever sent to the node, so real chain state never comes into +/// play for them — 0 is always correct there. +pub fn run_with_sequence( + context: &simplex::TestContext, + program: impl AsRef, + witness: W, + sequence: Sequence, + blocks_to_mine: u64, + expect: Expect, +) -> anyhow::Result<()> +where + W: WitnessTrait + 'static, +{ + let script = fund(context, &program)?; + + if blocks_to_mine > 0 { + let target = context.get_default_provider().fetch_tip_height()? as u64 + blocks_to_mine; + context.get_network_utils().mine_until_height(target)?; + } + + let result = spend_with_sequence(context, &program, &script, witness, sequence); + + assert_error_msg(result, expect) +} diff --git a/tests/timelocks_test.rs b/tests/timelocks_test.rs new file mode 100644 index 0000000..3882057 --- /dev/null +++ b/tests/timelocks_test.rs @@ -0,0 +1,275 @@ +mod common; + +use simplex::program::{ProgramTrait, WitnessTrait}; +use simplex::simplicityhl::elements::Sequence; +use simplex::transaction::{FinalTransaction, PartialInput, RequiredSignature}; + +use common::core::{Expect, fund, run_with_sequence}; + +use simplicityhl_std::artifacts::timelocks_test::TimelocksTestProgram; +use simplicityhl_std::artifacts::timelocks_test::derived_timelocks_test::{ + TimelocksTestArguments, TimelocksTestWitness, +}; + +// Dispatch indices — must match the `if_test_this_function(N, ..)` arms in +// simf/timelocks_test.simf. +enum FunctionToTest { + EnforceRelativeDistance, + EnforceRelativeDuration, +} + +#[inline] +fn op(o: FunctionToTest) -> u8 { + o as u8 +} + +// The `min_*` argument passed into the function under test in every case; only the +// *declared* (encoded) sequence value varies between test cases. +const MIN_DISTANCE: u16 = 5; +const MIN_DURATION: u16 = 5; + +fn program() -> TimelocksTestProgram { + TimelocksTestProgram::new(TimelocksTestArguments {}) +} + +fn build_witness(function: u8) -> TimelocksTestWitness { + TimelocksTestWitness { + function_index: function, + min_distance: MIN_DISTANCE, + min_duration: MIN_DURATION, + } +} + +// Raw BIP68 nSequence encodings. See rust-simplicity's `jets.c` (`parse_sequence`): +// bit 31 = disable flag (set => `None`), bit 22 = type flag (0 = blocks/Distance/Left, +// 1 = 512-second units/Duration/Right), low 16 bits = the declared value. +fn seq_disabled() -> Sequence { + Sequence::MAX +} + +fn seq_distance(blocks: u16) -> Sequence { + Sequence::from_consensus(u32::from(blocks)) +} + +fn seq_duration(units: u16) -> Sequence { + Sequence::from_consensus((1u32 << 22) | u32::from(units)) +} + +mod timelocks_test { + use super::*; + + // ---------- shared: transaction version precondition ---------- + + #[simplex::test] + fn tx_version_below_2_fails(context: simplex::TestContext) -> anyhow::Result<()> { + // `FinalTransaction` always builds a PSET-v2 transaction (tx version 2) and + // doesn't expose a version setter, so `run_with_sequence`/`Signer::broadcast` + // can't produce a version<2 transaction. This test drops one level lower: it + // extracts the PSET itself, overrides the version field directly, then calls + // `ProgramTrait::finalize` (the same local Simplicity execution that + // `Signer::broadcast` runs internally before ever touching the network) instead + // of going through `Signer::broadcast`, which would silently re-derive its own + // PSET from `FinalTransaction` and ignore the override. + // + // `pst.global.tx_data.version` is the right PSET-v2 field for `elements = + // 0.25.3` -- confirmed by this test passing. + let program = program(); + let witness = build_witness(op(FunctionToTest::EnforceRelativeDistance)); + + let script = fund(&context, &program)?; + let utxos = context + .get_default_provider() + .fetch_scripthash_utxos(&script)?; + + let mut ft = FinalTransaction::new(); + // Plain `add_input` (not `add_program_input`): `extract_pst()` only reads + // `partial_input`/outputs, so the program/witness don't need to be attached to + // `ft` here -- we hand them to `finalize()` directly below. + ft.add_input(PartialInput::new(utxos[0].clone()), RequiredSignature::None); + + let (mut pst, _secrets) = ft.extract_pst(); + pst.global.tx_data.version = 1; + + let witness_values = witness.build_witness(); + let result = program + .as_ref() + .finalize(&pst, &witness_values, 0, context.get_network()); + + let err = result.expect_err("expected tx version < 2 to fail Simplicity execution"); + assert!( + err.to_string().contains("Jet failed during execution"), + "unexpected error: {err}" + ); + + Ok(()) + } + + // ---------- enforce_relative_distance ---------- + + #[simplex::test] + fn distance_disabled_sequence_is_pruned(context: simplex::TestContext) -> anyhow::Result<()> { + // Disable flag set -> jet::parse_sequence returns None -> unwrap(None) hits the + // pruned branch. Never reaches broadcast, so no mining needed. + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_disabled(), + 0, + Expect::PrunedBranch, + ) + } + + #[simplex::test] + fn distance_wrong_variant_is_pruned(context: simplex::TestContext) -> anyhow::Result<()> { + // Sequence declares a Duration (type flag set), but the function requires a + // Distance -> unwrap_left panics on Right(_). 0 units is trivially BIP68-valid + // (irrelevant here anyway, since this fails before broadcast). + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_duration(0), + 0, + Expect::PrunedBranch, + ) + } + + #[simplex::test] + fn distance_below_minimum_fails(context: simplex::TestContext) -> anyhow::Result<()> { + // Declared distance is below MIN_DISTANCE -> le_16 assert fails. + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_distance(MIN_DISTANCE - 1), + 0, + Expect::AssertFailed, + ) + } + + #[simplex::test] + fn distance_equal_minimum_succeeds(context: simplex::TestContext) -> anyhow::Result<()> { + // Happy path: mine enough blocks that the declared distance is genuinely + // satisfied on-chain, so the real BIP68 check at broadcast also passes. + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_distance(MIN_DISTANCE), + u64::from(MIN_DISTANCE), + Expect::Ok, + ) + } + + #[simplex::test] + fn distance_above_minimum_succeeds(context: simplex::TestContext) -> anyhow::Result<()> { + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_distance(MIN_DISTANCE + 1), + u64::from(MIN_DISTANCE + 1), + Expect::Ok, + ) + } + + #[simplex::test] + fn distance_sufficient_declared_insufficient_real_is_rejected( + context: simplex::TestContext, + ) -> anyhow::Result<()> { + // Declared distance satisfies MIN_DISTANCE, so local execution succeeds and the + // transaction reaches the node. But we deliberately don't mine any extra blocks + // first, so real elapsed distance since the funding confirmation is far short of + // what's declared -> the node's own BIP68 check should reject the broadcast. + // This is the test that actually proves the function's guarantee is backed by + // consensus, not just by a value the spender is free to write into the tx. + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDistance)), + seq_distance(MIN_DISTANCE), + 0, + Expect::BroadcastRejected, + ) + } + + // ---------- enforce_relative_duration ---------- + + #[simplex::test] + fn duration_disabled_sequence_is_pruned(context: simplex::TestContext) -> anyhow::Result<()> { + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDuration)), + seq_disabled(), + 0, + Expect::PrunedBranch, + ) + } + + #[simplex::test] + fn duration_wrong_variant_is_pruned(context: simplex::TestContext) -> anyhow::Result<()> { + // Sequence declares a Distance, but the function requires a Duration -> + // unwrap_right panics on Left(_). + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDuration)), + seq_distance(0), + 0, + Expect::PrunedBranch, + ) + } + + #[simplex::test] + fn duration_below_minimum_fails(context: simplex::TestContext) -> anyhow::Result<()> { + // NOTE: this is a "weak" version of the case (declared duration insufficient, + // spend attempted at *some* real duration) rather than the full case (declared + // duration insufficient, spend attempted at a real duration that's genuinely + // sufficient for the *declared* value, isolating that the failure is really + // about MIN_DURATION and not an artifact of insufficient real elapsed time). + // Strengthening it that way would need median-time-past to have genuinely + // advanced, which hits the same missing `setmocktime` capability as the happy + // path above. It's a correctness non-issue either way -- this fails during + // local Simplicity execution inside `Signer::broadcast`, before the transaction + // is ever sent to the node, so real chain state can't affect the outcome here + // regardless -- but it does mean this test doesn't positively demonstrate that + // isolation the way `distance_below_minimum_fails` could (and doesn't yet). + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDuration)), + seq_duration(MIN_DURATION - 1), + 0, + Expect::AssertFailed, + ) + } + + #[simplex::test] + fn duration_sufficient_declared_insufficient_real_is_rejected( + context: simplex::TestContext, + ) -> anyhow::Result<()> { + // Duration counterpart of `distance_sufficient_declared_insufficient_real_is_rejected`. + // Unlike the duration *happy* path, this needs no mocktime: "real MTP hasn't + // advanced far enough yet" is simply the default state right after funding, not + // something that has to be faked. + run_with_sequence( + &context, + program(), + build_witness(op(FunctionToTest::EnforceRelativeDuration)), + seq_duration(MIN_DURATION), + 0, + Expect::BroadcastRejected, + ) + } + + // `duration_equal_minimum_succeeds` / `duration_above_minimum_succeeds` are + // intentionally not included here: they require median-time-past to have genuinely + // advanced by MIN_DURATION * 512 real seconds, which regtest can't be made to do + // without a `setmocktime`-based helper that Simplex doesn't currently expose (see + // `NetworkUtils`/`ElementsRpc` in `smplx/crates/test` and `smplx/crates/sdk`). Add + // them once that lands. +} + + From 7af8cba4501d24eff4504269bfa75dfa03987748 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 12 Aug 2026 15:45:14 -0700 Subject: [PATCH 3/5] Rename test to tests for consistency --- tests/timelocks_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/timelocks_test.rs b/tests/timelocks_test.rs index 3882057..e7307a3 100644 --- a/tests/timelocks_test.rs +++ b/tests/timelocks_test.rs @@ -55,7 +55,7 @@ fn seq_duration(units: u16) -> Sequence { Sequence::from_consensus((1u32 << 22) | u32::from(units)) } -mod timelocks_test { +mod timelocks_tests { use super::*; // ---------- shared: transaction version precondition ---------- From 1079110487f1a902474e7f857be984105fcb410d Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 12 Aug 2026 17:06:49 -0700 Subject: [PATCH 4/5] Update WISHLIST.md to note addition of timelock functions --- WISHLIST.md | 56 +---------------------------------------------------- 1 file changed, 1 insertion(+), 55 deletions(-) diff --git a/WISHLIST.md b/WISHLIST.md index 87dfc24..fbe5f09 100644 --- a/WISHLIST.md +++ b/WISHLIST.md @@ -103,60 +103,6 @@ Q. Are there (or could there be) standard protocols or conventions for (1) coven * `is_none`, `is_some` (as generic macros) * We currently have `is_none` where the type must be specified explicitly, but the compiler could figure out what it is and not require specifying it. * Oracle interpretation, once some oracle formats are standardized -* Relative timelocks (replacements for deprecated jets). - * Implementations: - * ```javascript - 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 input's - // UTXO. Panic otherwise. - - // Transaction version must be at least 2. - assert!(jet::le_32(2, jet::version())); - - // Fetch and parse sequence for current transaction - let parsed_seq: Option> = jet::parse_sequence(jet::current_sequence()); - - match parsed_seq { - // Failure condition - None => assert!(false), - // This is either a distance or a duration, but only a distance is - // acceptable here. - Some(actual_data: Either) => match actual_data { - // Is the actual distance greater than or equal to the specified min_distance? - Left(actual_distance: Distance) => assert!(jet::le_16(min_distance, actual_distance)), - // A duration is not acceptable in this context. - Right(actual_duration: Duration) => assert!(false), - }, - } - } - - fn enforce_relative_duration(min_duration: Duration) { - // Assert that the current input is spent in a transaction that can only - // appear a duration of at least min_duration units of 512 seconds after - // the input's UTXO. Panic otherwise. - - // Transaction version must be at least 2. - assert!(jet::le_32(2, jet::version())); - - // Fetch and parse sequence for current transaction - let parsed_seq: Option> = jet::parse_sequence(jet::current_sequence()); - - match parsed_seq { - // Failure condition - None => assert!(false), - // This is either a distance or a duration, but only a duration is - // acceptable here. - Some(actual_data: Either) => match actual_data { - // A distance is not acceptable in this context. - Left(actual_distance: Distance) => assert!(false), - // Is the actual duration greater than or equal to the specified min_duration? - Right(actual_duration: Duration) => assert!(jet::le_16(min_duration, actual_duration)), - }, - } - } - - ``` - * See also +√ Relative timelocks (replacements for deprecated jets). See also * Fee management? * More convenient SHA256? From 86097583e31986eaf391fff5f7380f0d4eaee311 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 14 Aug 2026 17:31:55 -0700 Subject: [PATCH 5/5] Note timelock functions in CHANGELOG --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a12e66..39710bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Add relative timelock enforcement functions (`enforce_relative_distance` + and `enforce_relative_duration`). These functions are replacements for + the deprecated jets `jet::check_lock_distance` and + `jet::check_lock_duration`. + ## [0.0.1] The initial release with checked arithmetic operations for `u8`, `u16`, `u32`, `u64`, and `u128`;