Skip to content

Instantly share code, notes, and snippets.

@tvolk131
Created March 25, 2026 04:03
Show Gist options
  • Select an option

  • Save tvolk131/e915ce4d39a86bf6229a1a7a6c84a85d to your computer and use it in GitHub Desktop.

Select an option

Save tvolk131/e915ce4d39a86bf6229a1a7a6c84a85d to your computer and use it in GitHub Desktop.
SimplicityHL Rust Integration Guide

Rust Integration Guide

This guide walks through the complete lifecycle of a SimplicityHL contract from Rust: compiling source code, constructing witnesses, serializing for the blockchain, and verifying execution with the Simplicity interpreter.

Prerequisites

# Cargo.toml
[dependencies]
simplicity = { package = "simplicity-lang", version = "0.7.0", features = ["elements", "base64"] }
elements = { version = "0.25.0", default-features = false }
hashes = { package = "bitcoin_hashes", version = "0.14" }
hex = { package = "hex-conservative", version = "0.2.1" }

The simplicity-lang crate re-exports elements and base64, so you can use simplicity::elements and simplicity::base64 without adding those crates directly.

Overview

A SimplicityHL contract goes through these stages:

Source code (.simf)
  │
  ▼  TemplateProgram::new()
TemplateProgram          ← parameterized, no concrete values yet
  │
  ▼  .instantiate(arguments)
CompiledProgram          ← commit-time: has CMR, can derive address
  │
  ▼  .satisfy(witness_values)
SatisfiedProgram         ← redeem-time: has witness, ready for chain
  │
  ▼  .redeem()
RedeemNode<Elements>     ← raw Simplicity node, can execute on BitMachine

At the low level (e.g. when loading a pre-compiled base64 program rather than compiling from source), the key types are:

  • CommitNode<Elements> — a Simplicity program without witness data
  • RedeemNode<Elements> — a Simplicity program with witness data attached
  • ElementsEnv — the transaction environment the program executes against
  • BitMachine — the Simplicity interpreter

Approach A: Compile from source in Rust

Use this when you want to compile SimplicityHL source code directly from your Rust program.

Step 1: Parse and compile

use simfony::{TemplateProgram, CompiledProgram, Arguments, WitnessValues};

// Parse source code
let source = r#"
fn main() {
    let lock_height: u32 = 800000;
    jet::check_lock_height(lock_height);
}
"#;

// For contracts with no parameters, use empty arguments
let args = Arguments::default();
let compiled = CompiledProgram::new(source, args, false)?;

For contracts with parameters (like param::PUBLIC_KEY):

// Load arguments from JSON
let args_json = r#"{
    "PUBLIC_KEY": {
        "value": "0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
        "type": "Pubkey"
    }
}"#;
let args: Arguments = serde_json::from_str(args_json)?;

let compiled = CompiledProgram::new(source, args, false)?;

Step 2: Extract commit-time data

let commit_node = compiled.commit();

// CMR (Commitment Merkle Root) — identifies the contract on-chain
let cmr = commit_node.cmr();
println!("CMR: {}", cmr);

// Serialized program (for embedding in a transaction)
let program_bytes = commit_node.encode_to_vec();

use simplicity::base64::engine::general_purpose::STANDARD;
use simplicity::base64::Engine;
let program_b64 = STANDARD.encode(&program_bytes);
println!("Program: {}", program_b64);

Step 3: Satisfy with witness data

// For contracts with no witness (like a pure timelock), use empty witness
let witness = WitnessValues::default();
let satisfied = compiled.satisfy(witness)?;

// For contracts with witness values:
let wit_json = r#"{
    "SIGNATURE": {
        "value": "0xf74b3ca5...signature hex...",
        "type": "Signature"
    }
}"#;
let witness: WitnessValues = serde_json::from_str(wit_json)?;
let satisfied = compiled.satisfy(witness)?;

Step 4: Extract redeem-time data

let redeem_node = satisfied.redeem();

// Separate program and witness encodings for the transaction
let program_bytes = redeem_node.encode_to_vec();
let witness_bytes = redeem_node.encode_witness_to_vec();

let program_b64 = STANDARD.encode(&program_bytes);
let witness_b64 = STANDARD.encode(&witness_bytes);

Approach B: Load a pre-compiled program

Use this when working with base64 output from simc (e.g., from a CI pipeline or CLI workflow).

Decoding a CommitNode (program without witness)

use simplicity::base64::engine::general_purpose::STANDARD;
use simplicity::base64::Engine;
use simplicity::node::CommitNode;
use simplicity::jet::Elements;
use simplicity::BitIter;

let program_base64 = "3JsgAMNQAEIFCDPAISKD6AQDSA==";  // from simc --json
let program_bytes = STANDARD.decode(program_base64)?;

let program_iter = BitIter::from(program_bytes.as_slice());
let commit = CommitNode::<Elements>::decode(program_iter)?;

println!("CMR: {}", commit.cmr());

Decoding a RedeemNode (program + witness)

This is the critical API that most developers need. RedeemNode::decode() takes two BitIter arguments — one for the program and one for the witness:

use simplicity::{BitIter, RedeemNode};
use simplicity::jet::Elements;

// Both come from simc --json output
let program_bytes = STANDARD.decode(program_base64)?;
let witness_bytes = STANDARD.decode(witness_base64)?;

let program_iter = BitIter::from(program_bytes.as_slice());
let witness_iter = BitIter::from(witness_bytes.as_slice());

let program = RedeemNode::<Elements>::decode(program_iter, witness_iter)?;

For contracts with no witness data, pass an empty slice:

let witness_iter = BitIter::from([].as_slice());
let program = RedeemNode::<Elements>::decode(program_iter, witness_iter)?;

Executing with the Simplicity interpreter

Once you have a RedeemNode, you can run it against a transaction environment using the BitMachine.

Constructing an ElementsEnv

ElementsEnv represents the transaction context that the Simplicity program executes against. It contains the spending transaction, the UTXOs being spent, and taproot commitment data.

use simplicity::BitMachine;
use simplicity::jet::elements::{ElementsEnv, ElementsUtxo};
use elements::taproot::ControlBlock;
use std::sync::Arc;

// Build the spending transaction
let tx = Arc::new(elements::Transaction {
    version: 2,
    lock_time: elements::LockTime::from_height(800000).unwrap(),
    input: vec![elements::TxIn {
        previous_output: elements::OutPoint::default(),
        is_pegin: false,
        script_sig: elements::Script::new(),
        sequence: elements::Sequence::ENABLE_LOCKTIME_NO_RBF,
        asset_issuance: elements::AssetIssuance::default(),
        witness: elements::TxInWitness::default(),
    }],
    output: Vec::default(),
});

// Describe the UTXO being spent
let utxos = vec![ElementsUtxo {
    script_pubkey: elements::Script::new(),
    asset: elements::confidential::Asset::Null,
    value: elements::confidential::Value::Null,
}];

// Control block for taproot (33 bytes minimum: 1-byte leaf version + 32-byte internal key)
let ctrl_blk: [u8; 33] = [
    0xc0, // leaf version
    // 32-byte internal key (BIP-341 default for keypath-disabled scripts)
    0xeb, 0x04, 0xb6, 0x8e, 0x9a, 0x26, 0xd1, 0x16,
    0x04, 0x6c, 0x76, 0xe8, 0xff, 0x47, 0x33, 0x2f,
    0xb7, 0x1d, 0xda, 0x90, 0xff, 0x4b, 0xef, 0x53,
    0x70, 0xf2, 0x52, 0x26, 0xd3, 0xbc, 0x09, 0xfc,
];

let env = ElementsEnv::new(
    tx,                                             // spending transaction
    utxos,                                          // UTXOs being consumed
    0,                                              // index of the input being verified
    program.cmr(),                                  // CMR of the Simplicity program
    ControlBlock::from_slice(&ctrl_blk).unwrap(),   // taproot control block
    None,                                           // optional annex
    elements::BlockHash::all_zeros(),               // genesis block hash
);

Running the program

let mut machine = BitMachine::for_program(&program)?;
match machine.exec(&program, &env) {
    Ok(_) => println!("Program executed successfully — spend is valid"),
    Err(e) => println!("Program failed: {}", e),
}

BitMachine is single-use: create a fresh instance for each execution via BitMachine::for_program().

Complete example: timelock contract

This example compiles a timelock contract, decodes it, and verifies that it enforces the lock height correctly by testing execution at different block heights.

Contract source (timelock.simf)

fn main() {
    let lock_height: u32 = 800000;
    jet::check_lock_height(lock_height);
}

Rust verification

use simplicity::base64::engine::general_purpose::STANDARD;
use simplicity::base64::Engine;
use simplicity::{BitIter, BitMachine, RedeemNode};
use simplicity::jet::elements::{ElementsEnv, ElementsUtxo};
use elements::taproot::ControlBlock;
use std::sync::Arc;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Step 1: Compile via simc (or use pre-compiled base64)
    let output = std::process::Command::new("simc")
        .args(["--json", "timelock.simf"])
        .output()?;
    let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
    let program_base64 = json["program"].as_str().unwrap();

    // Step 2: Decode into a RedeemNode (no witness needed for timelocks)
    let program_bytes = STANDARD.decode(program_base64)?;
    let program = RedeemNode::<simplicity::jet::Elements>::decode(
        BitIter::from(program_bytes.as_slice()),
        BitIter::from([].as_slice()),  // empty witness
    )?;

    let cmr = program.cmr();

    // Step 3: Build a helper to create test environments at different heights
    let make_env = |height: u32| -> ElementsEnv<Arc<elements::Transaction>> {
        let ctrl_blk = [0xc0u8; 33]; // simplified for example
        ElementsEnv::new(
            Arc::new(elements::Transaction {
                version: 2,
                lock_time: elements::LockTime::from_height(height).unwrap(),
                input: vec![elements::TxIn {
                    previous_output: elements::OutPoint::default(),
                    is_pegin: false,
                    script_sig: elements::Script::new(),
                    sequence: elements::Sequence::ENABLE_LOCKTIME_NO_RBF,
                    asset_issuance: elements::AssetIssuance::default(),
                    witness: elements::TxInWitness::default(),
                }],
                output: Vec::default(),
            }),
            vec![ElementsUtxo {
                script_pubkey: elements::Script::new(),
                asset: elements::confidential::Asset::Null,
                value: elements::confidential::Value::Null,
            }],
            0,
            cmr,
            ControlBlock::from_slice(&[0xc0; 33]).unwrap(),
            None,
            elements::BlockHash::all_zeros(),
        )
    };

    // Step 4: Test at different block heights
    // Before lock height — should fail
    let mut machine = BitMachine::for_program(&program)?;
    assert!(machine.exec(&program, &make_env(799_999)).is_err());

    // At lock height — should succeed
    let mut machine = BitMachine::for_program(&program)?;
    assert!(machine.exec(&program, &make_env(800_000)).is_ok());

    // After lock height — should succeed
    let mut machine = BitMachine::for_program(&program)?;
    assert!(machine.exec(&program, &make_env(900_000)).is_ok());

    println!("All timelock tests passed!");
    Ok(())
}

Complete example: escrow with two spending paths

This example demonstrates a contract with multiple spending paths using Either witnesses, and verifies both paths.

Contract source (escrow.simf)

fn checksig(pk: Pubkey, sig: Signature) {
    let msg: u256 = jet::sig_all_hash();
    jet::bip_0340_verify((pk, msg), sig);
}

fn release_spend(buyer_sig: Signature, seller_sig: Signature) {
    let buyer_pk: Pubkey = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798;
    let seller_pk: Pubkey = 0xc6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5;
    checksig(buyer_pk, buyer_sig);
    checksig(seller_pk, seller_sig);
}

fn refund_spend(buyer_sig: Signature) {
    let buyer_pk: Pubkey = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798;
    checksig(buyer_pk, buyer_sig);
    let timeout: Distance = 144;
    jet::check_lock_distance(timeout);
}

fn main() {
    match witness::RELEASE_OR_REFUND {
        Left(sigs: (Signature, Signature)) => {
            let (buyer_sig, seller_sig): (Signature, Signature) = sigs;
            release_spend(buyer_sig, seller_sig);
        },
        Right(buyer_sig: Signature) => refund_spend(buyer_sig),
    }
}

Witness files

The contract has a single witness RELEASE_OR_REFUND of type Either<(Signature, Signature), Signature>.

Release path (escrow.release.wit) — both parties sign:

{
    "RELEASE_OR_REFUND": {
        "value": "Left((0x<buyer_sig_hex>, 0x<seller_sig_hex>))",
        "type": "Either<(Signature, Signature), Signature>"
    }
}

Refund path (escrow.refund.wit) — buyer reclaims after timeout:

{
    "RELEASE_OR_REFUND": {
        "value": "Right(0x<buyer_sig_hex>)",
        "type": "Either<(Signature, Signature), Signature>"
    }
}

Important: Witness Either values use string syntax"Left(...)" and "Right(...)". Do not use JSON objects like {"Left": ...}.

For contracts with 3+ paths, nest them: "Right(Left(...))", "Right(Right(...))".

Rust verification

use simplicity::base64::engine::general_purpose::STANDARD;
use simplicity::base64::Engine;
use simplicity::{BitIter, RedeemNode};
use simplicity::node::CommitNode;
use simplicity::jet::Elements;

fn verify_program(program_b64: &str, witness_b64: Option<&str>) -> Result<(), String> {
    let program_bytes = STANDARD.decode(program_b64).map_err(|e| e.to_string())?;

    if let Some(wit_b64) = witness_b64 {
        // Decode as RedeemNode (program + witness)
        let witness_bytes = STANDARD.decode(wit_b64).map_err(|e| e.to_string())?;
        let program = RedeemNode::<Elements>::decode(
            BitIter::from(program_bytes.as_slice()),
            BitIter::from(witness_bytes.as_slice()),
        ).map_err(|e| e.to_string())?;

        println!("RedeemNode decoded — CMR: {}", program.cmr());
    } else {
        // Decode as CommitNode (program only, no witness)
        let commit = CommitNode::<Elements>::decode(
            BitIter::from(program_bytes.as_slice()),
        ).map_err(|e| e.to_string())?;

        println!("CommitNode decoded — CMR: {}", commit.cmr());
    }

    Ok(())
}

Common imports

For quick reference, here are the imports used across these examples:

// Core Simplicity types
use simplicity::{BitIter, BitMachine, RedeemNode};
use simplicity::node::CommitNode;
use simplicity::jet::Elements;
use simplicity::jet::elements::{ElementsEnv, ElementsUtxo};

// Base64 encoding (re-exported by simplicity-lang)
use simplicity::base64::engine::general_purpose::STANDARD;
use simplicity::base64::Engine;

// Elements transaction types (re-exported by simplicity-lang)
use elements::taproot::ControlBlock;

// SimplicityHL compiler API (when compiling from source)
use simfony::{TemplateProgram, CompiledProgram, SatisfiedProgram};
use simfony::{Arguments, WitnessValues};

Troubleshooting

RedeemNode::decode() fails with an unexpected error

RedeemNode::decode() requires two BitIter arguments: one for the program bytes and one for the witness bytes. A common mistake is passing only the program. For contracts with no witness data, pass an empty iterator:

BitIter::from([].as_slice())

ElementsEnv constructors not available outside tests

ElementsEnv::dummy_with() is only available behind #[cfg(test)]. In standalone binaries, construct ElementsEnv::new() directly with all 7 parameters as shown in the ElementsEnv section above.

SHA-256 jet type mismatches

The _add_N suffix on SHA-256 context jets refers to the number of bytes added, not the bit width:

Jet Input type Meaning
sha_256_ctx_8_add_1 u8 add 1 byte
sha_256_ctx_8_add_8 u64 add 8 bytes
sha_256_ctx_8_add_32 u256 add 32 bytes

check_lock_height vs check_lock_distance

  • check_lock_height(h) — absolute timelock: fails if current block height < h
  • check_lock_distance(d) — relative timelock: fails if blocks elapsed since UTXO creation < d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment