Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`;
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 1 addition & 55 deletions WISHLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<Either<Distance, Duration>> = 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<Distance, Duration>) => 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<Either<Distance, Duration>> = 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<Distance, Duration>) => 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 <https://docs.simplicity-lang.org/documentation/timelock>
√ Relative timelocks (replacements for deprecated jets). See also <https://docs.simplicity-lang.org/documentation/timelock>
* Fee management?
* More convenient SHA256?
35 changes: 35 additions & 0 deletions simf/lib/timelocks.simf
Original file line number Diff line number Diff line change
@@ -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<Distance, Duration> = unwrap(jet::parse_sequence(jet::current_sequence()));
let actual_distance: Distance = unwrap_left::<Duration>(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<Distance, Duration> = unwrap(jet::parse_sequence(jet::current_sequence()));
let actual_duration: Duration = unwrap_right::<Duration>(actual_data);

assert!(jet::le_16(min_duration, actual_duration));
}
11 changes: 11 additions & 0 deletions simf/timelocks_test.simf
Original file line number Diff line number Diff line change
@@ -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 => (), };
}
118 changes: 110 additions & 8 deletions tests/common/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -16,21 +16,28 @@ 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")
}
}
}
}

/// Send sats to the program's script so it has a UTXO to spend.
pub fn fund(
context: &simplex::TestContext,
Expand Down Expand Up @@ -93,15 +100,32 @@ pub fn assert_error_msg(
result: Result<String, anyhow::Error>,
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}");
}
};

Expand Down Expand Up @@ -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<W>(
context: &simplex::TestContext,
program: &impl AsRef<Program>,
script: &Script,
witness: W,
sequence: Sequence,
) -> anyhow::Result<FinalTransaction>
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<W>(
context: &simplex::TestContext,
program: &impl AsRef<Program>,
script: &Script,
witness: W,
sequence: Sequence,
) -> anyhow::Result<String>
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<W>(
context: &simplex::TestContext,
program: impl AsRef<Program>,
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)
}
Loading