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.
# 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.
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 dataRedeemNode<Elements>— a Simplicity program with witness data attachedElementsEnv— the transaction environment the program executes againstBitMachine— the Simplicity interpreter
Use this when you want to compile SimplicityHL source code directly from your Rust program.
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)?;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);// 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)?;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);Use this when working with base64 output from simc (e.g., from a CI pipeline or CLI workflow).
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());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)?;Once you have a RedeemNode, you can run it against a transaction environment using the BitMachine.
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
);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().
This example compiles a timelock contract, decodes it, and verifies that it enforces the lock height correctly by testing execution at different block heights.
fn main() {
let lock_height: u32 = 800000;
jet::check_lock_height(lock_height);
}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(())
}This example demonstrates a contract with multiple spending paths using Either witnesses, and verifies both paths.
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),
}
}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
Eithervalues use string syntax —"Left(...)"and"Right(...)". Do not use JSON objects like{"Left": ...}.For contracts with 3+ paths, nest them:
"Right(Left(...))","Right(Right(...))".
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(())
}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};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::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.
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(h)— absolute timelock: fails if current block height <hcheck_lock_distance(d)— relative timelock: fails if blocks elapsed since UTXO creation <d