The Sapio Language
Designing Bitcoin Contracts with Sapio
A practical guide to engineering bitcoin smart contracts using the Sapio Language.
Begin with Installing Sapio and Build and spend your first contract. These chapters use the maintained generated starter. Other chapters retain conceptual material and historical sketches; they are not all independently compiled tutorials. Current API and release boundaries are documented in the repository’s quickstart.
This book is a work in progress! Please submit a PR with improvements or suggestions.
Introduction
Sapio is a Rust framework for describing Bitcoin contracts as transaction graphs. It separates constructing transactions, defining their spending policies and supplying the signatures or program evidence needed to complete a selected spend.
The first lesson uses the maintained generated starter and its tested contract source. Later chapters include historical examples and conceptual sketches; use the current repository guides when an older sketch differs from the public API. Rendering this book does not compile every code block.
Who is Sapio For?
Sapio is for anyone who wants to build with Bitcoin. That spans students demonstrating research concepts, corporations working on custody solutions, and developers improving open source solutions. Sapio is not a Solidity equivalent. The programming model is very different. But it does help anyone trying to solve a transactional protocol for Bitcoin solve it elegantly.
The developer preview uses synthetic funding and public demonstration keys. Native covenant research and oracle-based emulation have distinct enforcement assumptions. Completing the lesson does not establish production readiness; the release-readiness record lists the remaining gates.
What will I learn if I read this book?
This book is intended to teach you how to think about programming Sapio contracts. The book contains some exercises (that are heavily encouraged) that should instigate your understanding of how to build smart contracts for Bitcoin.
If you go through the chapters in order and complete all the exercises you should develop a firm grasp of how to use Sapio, how it works, and how it will progress over time. You will also have sufficient understanding to contribute back meaningfully to the open source project.
Getting Started
Start with one generated Rust project and carry a payment through inspection, explicit signing and finalization:
- Setting up an environment
- Learning Rust
- Build and spend your first contract
Installing Sapio
Use Rust with rustup, Git and a native C compiler. The maintained developer preview starts from one source checkout and generates an independent project:
git clone https://github.com/sapio-lang/sapio.git
cd sapio
cargo build --locked -p sapio-cli
export PATH="$PWD/target/debug:$PATH"
sapio-cli new ../my-contract --name my-contract
cd ../my-contract
cargo test --locked
Rustup selects the checked-in stable toolchain. The project includes its full dependency pins, Cargo lockfile and exact payment evaluator bytes. Cargo fetches the required repositories; no manually cloned dependency libraries, nightly toolchain, wasm-pack, container or graphical application are required.
If you use CARGO_TARGET_DIR, put its debug subdirectory on PATH instead.
The maintained quickstart
and generated README.md continue through artifact inspection and a completed
synthetic spend. The example has public demonstration keys and must not receive
real funds.
Building compiler plugins as WASM is a separate workflow. It additionally needs
LLVM Clang with the wasm32 target; see the
development guide.
The starter does not need to rebuild its included evaluator.
Learning Rust
A full rust tutorial is out of scope for this guide.
You may wish to begin with The Rust Programming Language.
Deep expertise in Rust is not required to be a fluent Sapio developer, but it helps. Typical Sapio programs are relatively simple as we are not typically concerned with concurrency or memory efficiency.
Build and spend your first contract
The starter’s payment contract fixes a recipient, minimum amount, evaluator and public oracle root. A typed action proposes payments under that fixed policy. Its contract definition is separate from synthetic funding, private demonstration keys, file handling and tests.
This source is included directly from the generated project’s contract module:
//! A payment predicate and the transactions its typed action proposes.
use bitcoin::{Address, Amount};
use sapio::contract::{CompilationError, Compiled, Context};
use sapio::template::{OutputAmount, Template};
use sapio_base::program::{EmulatedProgram, EvaluatorId, ProgramError, ProgramInstance};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// A candidate may pay more than the minimum fixed by the contract.
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct PaymentRequest {
pub amount_sats: u64,
}
/// The oracle must enforce the committed recipient and minimum honestly.
pub struct Payment {
recipient: Address,
change: Address,
authorization: EmulatedProgram,
}
#[sapio::contract]
impl Payment {
pub fn new(
recipient: Address,
change: Address,
minimum: Amount,
evaluator: EvaluatorId,
oracle: bitcoin::bip32::Xpub,
) -> Result<Self, ProgramError> {
let script = recipient.script_pubkey();
// The distributed pay-at-least/v1 evaluator defines this exact codec.
let mut parameters = minimum.to_sat().to_le_bytes().to_vec();
parameters.extend_from_slice(&(script.len() as u32).to_le_bytes());
parameters.extend_from_slice(script.as_bytes());
let instance = ProgramInstance::new(evaluator, b"pay-at-least/v1".to_vec(), parameters)?;
Ok(Self {
recipient,
change,
authorization: EmulatedProgram::new(instance, oracle)?,
})
}
#[policy]
fn authorize(&self) -> EmulatedProgram {
self.authorization.clone()
}
/// Construct a proposal; only the fixed policy can authorize its spend.
#[action(suggested, guarded_by(Self::authorize))]
pub fn pay(&self, ctx: Context, request: PaymentRequest) -> Result<Template, CompilationError> {
let recipient = Compiled::from_address(self.recipient.clone(), Amount::ZERO);
let change = Compiled::from_address(self.change.clone(), Amount::ZERO);
let mut plan = ctx.template_plan();
plan.output(
"recipient",
OutputAmount::Exact(Amount::from_sat(request.amount_sats)),
&recipient,
)?;
plan.output("change", OutputAmount::Remainder, &change)?;
plan.reserve_fees(Amount::from_sat(500));
plan.require_feerate(
bitcoin::FeeRate::from_sat_per_vb(1).expect("valid constant fee rate"),
);
Ok(plan.finish()?)
}
}
#[sapio::contract] preserves ordinary Rust methods. The #[policy] method
defines authorization; #[action(suggested)] exposes a typed PaymentRequest.
TemplatePlan declares exact recipient funding, change and a local fee
reservation. A proposal below the fixed minimum can be constructed, but the
evaluator rejects its signing request. Changing a proposed amount does not
change the contract’s address.
Continue from the project created in the installation chapter with its
generated README, also available as the
starter walkthrough.
It creates the demo directory and gives the exact contract explain,
contract spend and explicit local
signer program commands. You can stop between commands and resume with the
immutable artifact/intent and latest partial PSBT.
Bitcoin validates an ordinary Taproot signature here. The oracle enforces the WASM payment predicate; the chain does not execute it. Funding and keys are synthetic demonstration inputs, and nothing is broadcast.
After completing the walkthrough, change PaymentRequest and its pay method,
then update src/tests.rs to express the behavior you intend. The surrounding
book retains historical examples and conceptual sketches; use this maintained
starter as the reference for a complete, runnable first project.
BIP-119 CTV Fundamentals
Background
BIP-119 OP_CHECKTEMPLATEVERIFY (CTV) is a proposed soft-fork upgrade to Bitcoin for enabling a bevy of use cases.
At it’s core, CTV enables a script to commit to the “important bits” of how it can be spent, or the:
- nVersion
- nLockTime
- scriptSig hash (maybe!)
- input count
- sequences hash
- output count
- outputs hash
- input index
This enables a myriad of use cases, which are described in detail in the BIP and on the website utxos.org.
How do we think about Smart Contracts and CTV?
Before CTV, in most Bitcoin smart contracts, we think at the key-level. That is, what is a complex set of signers and satisfactions to unlock a specific coin. But once we unlock a coin, the smart contract usually does not encode any further restrictions on how it may be spent.
You could think of this as “a key to a car”. If it unlocks the car, you can take the car wherever you want.
With CTV, we hope to encode a bit more information about how coins should move by providing the paths that the coins must move through as well. So rather than just being the key to a car, you could think of it a bit more like the keys to train – still required to start the engine, but you have to stay on the tracks and there is a finite number of tracks to pick at any juncture.
That’s all a bit abstract. Think back to the Hello World example we saw earlier. We created a coin with the following options:
- Alice and Bob Agree \( \rightarrow \) coin goes anywhere
- Timeout \( \rightarrow \) coins go back to Alice and Bob
Now imagine we wanted to change the rules a little. What if instead of rule 2 apply after a timeout, what if we wanted the timeout to be measured from the time that Alice or Bob claimed they wanted to use the escrow.
This puts us in a little bit of a pickle. Sure we could just re-write the rules:
- Alice and Bob Agree \( \rightarrow \) coin goes anywhere
- Timeout since Alice or Bob requested \( \rightarrow \) coins go back to Alice and Bob
But Bitcoin doesn’t have a script level notion of “since” a part of a witness was constructed. The CTV way to think of this script is to define a state machine with two states \( S \in \{Normal, Closing\}\) and the rules:
-
\( S \gets Normal\):
- Alice and Bob Agree \( \rightarrow \) coin goes anywhere
- Alice or Bob Requested \( \rightarrow \) (\(S \gets Closing \))
-
\( S \gets Closing\):
- Alice and Bob Agree \( \rightarrow \) coin goes anywhere
- Timeout since (\(S \gets Closing\)) \( \rightarrow \) coins go back to Alice and Bob.
What drives the transition from Normal to Closing? Just a standard Bitcoin transaction!
So What is Sapio
Sapio is an embedded domain specific language for defining these sorts of state transition rules to build smart contracts for Bitcoin.
CTV is used as the mechanism to enforce that specific state transitions occur.
When we write a program in Sapio, we are designing an arbitrary state machine that can run any program.
When we compile a Sapio program, we run that state machine to completion and merkelize the resultant program states into a fixed graph.
As such, Sapio is a very powerful framework for designing Bitcoin smart contracts, but we’re constrained to the set of contracts where we can enumerate all possible end states.
To get around these restrictions, Sapio has some tricks up it’s sleeve that will be described in future chapters.
Sapio Basics
This section is intended to introduce the basic components of Sapio and how they are used. It’s a nice complement to the material available in the online docs, which are more targeted to everyday users.
Contract Guts
This section covers basic modules and primitives that are handy to know as you navigate Sapio contracts.
Feel free to skip this section and refer back to it as needed!
Miniscript & Policy
Miniscript & Policy are tools for creating well formed Bitcoin scripts developed by Blockstream developers Pieter Wiulle, Andrew Poelstra, and Sanket Kanjalkar.
From the miniscript website:
Miniscript is a language for writing (a subset of) Bitcoin Scripts in a structured way, enabling analysis, composition, generic signing and more.
Bitcoin Script is an unusual stack-based language with many edge cases, designed for implementing spending conditions consisting of various combinations of signatures, hash locks, and time locks. Yet despite being limited in functionality it is still highly nontrivial to:
- Given a combination of spending conditions, finding the most economical script to implement it.
- Given two scripts, construct a script that implements a composition of their spending conditions (e.g. a multisig where one of the “keys” is another multisig).
- Given a script, find out what spending conditions it permits.
- Given a script and access to a sufficient set of private keys, construct a general satisfying witness for it.
- Given a script, be able to predict the cost of spending an output.
- Given a script, know whether particular resource limitations like the ops limit might be hit when spending.
Miniscript functions as a representation for scripts that makes these sort of operations possible. It has a structure that allows composition. It is very easy to statically analyze for various properties (spending conditions, correctness, security properties, malleability, …). It can be targeted by spending policy compilers (see below). Finally, compatible scripts can easily be converted to Miniscript form - avoiding the need for additional metadata for e.g. signing devices that support it.
For Sapio, we use a customized fork of rust-miniscript library which extends miniscript with functionality relevant to CheckTemplateVerify and Sapio. All changes should be able to be upstreamed… eventually.
The Policy type (named Clause in Sapio) allows us to specify the predicates upon which various state transitions should unlock.
This makes it so that Sapio should be compatible with other software that can generate valid Policies, and compatible with PSBT signing devices that understand how to satisfy miniscripts.
A limitation of this approach is that there are certain types of script which
are possible, but not yet supported in Sapio. For example, the OP_SIZE coin
flip script is not currently possible with Miniscript. Another limitation of
Miniscript is that keys may not be repeated to preserve a guarantee of non
malleability.
Miniscript & Policy are an ongoing research concern. As they develop, Sapio will benefit from this foundational work.
Transaction plans
Start with a plan when a contract needs exact payments, change, a fee budget or named sponsor inputs. The plan resolves allocations before compiling children, then freezes the exact ordered transaction into the compiler’s usual template.
The runnable payment example uses ordinary Rust methods under #[sapio::contract].
Its committed action declares one recipient and reserves a fee. Try it from the
repository root:
cargo run --locked -p sapio --example payment
The complete source below is included directly from the tested example:
//! Compile a small payment contract without a node, signer, or WASM runtime.
//! Native CTV is a research target; this example does not fund or broadcast it.
use bitcoin::{Address, Amount, Network};
use sapio::contract::{Compilable, CompilationError, Compiled, Context};
use sapio::template::{OutputAmount, Template};
use sapio_base::covenant::LoweringPlan;
use sapio_base::effects::EffectPath;
use std::sync::Arc;
struct Payment {
destination: Compiled,
}
#[sapio::contract]
impl Payment {
#[action(committed)]
fn pay(&self, ctx: Context) -> Result<Template, CompilationError> {
let mut plan = ctx.template_plan();
plan.output(
"recipient",
OutputAmount::Exact(Amount::from_sat(1_000)),
&self.destination,
)?;
plan.reserve_fees(Amount::from_sat(500));
plan.require_feerate(bitcoin::FeeRate::from_sat_per_vb(1).unwrap());
Ok(plan.finish()?)
}
}
fn compile_payment(funding: u64) -> Result<Compiled, Box<dyn std::error::Error>> {
let destination = "bcrt1qumrrqgt7e3a7damzm8x97m6sjs20u8hjw2hcjj"
.parse::<Address<bitcoin::address::NetworkUnchecked>>()?
.require_network(Network::Regtest)?;
let contract = Payment {
destination: Compiled::from_address(destination, bitcoin::Amount::ZERO),
};
let compiled = contract.compile(Context::new(
Network::Regtest,
Amount::from_sat(funding),
LoweringPlan::Native,
EffectPath::try_from("payment")?,
Arc::new(Default::default()),
None,
))?;
compiled.validate()?;
Ok(compiled)
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let compiled = compile_payment(1_500)?;
serde_json::to_writer_pretty(
std::io::stdout().lock(),
&serde_json::json!({
"network": "regtest",
"enforcement": "native_ctv_research",
"funding_satoshis": 1_500,
"contract": compiled,
}),
)?;
Ok(())
}
#[test]
fn payment_preserves_destination_value_and_fee_reserve() {
let compiled = compile_payment(1_500).unwrap();
assert_eq!(compiled.ctv_to_tx.len(), 1);
let template = compiled.ctv_to_tx.values().next().unwrap();
assert_eq!(template.tx.input.len(), 1);
assert_eq!(template.tx.output.len(), 1);
assert_eq!(template.tx.output[0].value.to_sat(), 1_000);
let destination = "bcrt1qumrrqgt7e3a7damzm8x97m6sjs20u8hjw2hcjj"
.parse::<Address<bitcoin::address::NetworkUnchecked>>()
.unwrap()
.require_network(Network::Regtest)
.unwrap();
assert_eq!(
template.tx.output[0].script_pubkey,
destination.script_pubkey()
);
assert_eq!(template.required_input_amount.to_sat(), 1_500);
assert!(compile_payment(1_499).is_err());
}
OutputAmount::Remainder can assign change to one additional output. Output order
is declaration order; the resolver never sorts destinations. Auxiliary inputs
have names and declared minimum contributions, checked again when funding is
supplied. Repeated fee reservations take their maximum, and height/time lock
conflicts are explicit errors.
A fee cap is a local preparation rule. It is not automatically enforced by Bitcoin Script. Final fee-rate checks also need actual previous outputs and the completed witness weight. Native signature finalization and chain checks remain separate from transaction construction.
The lower-level builder described next is useful for procedural constructions.
Both APIs produce the same Template.
Template Builder
The template builder defines a transaction step. Each output receives part of the available funding and its own compilation context. Reserve fees explicitly after adding all outputs.
This contract pays 1,000 sats, returns the remaining funds as change, and reserves 100 sats for fees:
#![allow(unused)]
fn main() {
use bitcoin::{Amount, XOnlyPublicKey};
use sapio::contract::{CompilationError, Contract};
use sapio::{declare, then, Context};
struct Payment {
recipient: XOnlyPublicKey,
change: XOnlyPublicKey,
}
impl Payment {
#[then]
fn pay(self, ctx: Context) {
let fee = Amount::from_sat(100);
let mut tmpl = ctx
.template()
.set_label("Payment".into())
.add_output(Amount::from_sat(1_000), &self.recipient, None)?;
let change = tmpl
.ctx()
.funds()
.checked_sub(fee)
.ok_or(CompilationError::OutOfFunds)?;
if change != Amount::ZERO {
tmpl = tmpl.add_output(change, &self.change, None)?;
}
tmpl.add_fees(fee)?.into()
}
}
impl Contract for Payment {
declare! {actions, Self::pay}
}
}
Builder methods consume the previous builder and return the updated one.
ctx().funds() reports the remaining construction budget. Emitted outputs and
explicitly reserved fees determine the template’s minimum funding requirement;
an unused budget is not a fee reservation.
add_output passes the output’s amount to the receiving contract. When ordinal
ranges are known, outputs receive consecutive prefixes in transaction input
order. Debiting the builder without creating an output would shift those
ordinal assignments, so direct spend_amount access is private.
add_fees records the fee and changes the builder’s state. That state allows
additional fee reservations within the remaining budget, but cannot add more
outputs or auxiliary funds. This keeps fees after all outputs. Complete
metadata, guards, input sequences and change outputs before reserving fees.
Auxiliary input contributions use add_sequence().add_amount(amount)?; these
funds are separate from the contract input’s required contribution. When input
ordinals are tracked, allocate all known sats to outputs before introducing
unknown auxiliary funds. Binding checks the actual funding inputs.
Sapio currently places the contract’s UTXO at input zero. The CTV commitment includes this index. See the builder implementation for the complete set of operations.
Time Locks
Sapio provides typed absolute and relative timelocks in
sapio_base::timelocks. Heights and times have separate constructors so a
value cannot silently change its interpretation.
use sapio_base::timelocks::*;
use sapio_base::Clause;
use std::convert::{TryFrom, TryInto};
use std::time::Duration;
fn main() -> Result<(), LockTimeError> {
let height = AbsHeight::try_from(800_000u32)?;
let timestamp = AbsTime::try_from(1_000_000_000u32)?;
let same_timestamp = AbsTime::try_from(Duration::from_secs(1_000_000_000))?;
// Relative time is encoded in intervals of 512 seconds.
let intervals = RelTime::from(10u16);
let duration = RelTime::try_from(Duration::from_secs(10 * 512))?;
let blocks = RelHeight::from(20u16);
// Converting a transaction timelock into a policy is fallible.
let older: Clause = blocks.try_into()?;
let after: Clause = height.try_into()?;
let relative: AnyRelTimeLock = blocks.into();
let any: AnyTimeLock = relative.into();
let also_older = Clause::try_from(any)?;
Ok(())
}
RelTime::try_from(Duration) rounds up to whole 512-second intervals, so a
fractional interval cannot make the lock mature early. AbsTime rounds a
fractional timestamp up to a whole second. Both reject durations outside their
encoded range. The types’ JSON values are their encoded consensus fields;
relative time includes the time-type flag, not just a count of seconds.
Transaction fields and policy guards
Transaction fields and Miniscript predicates have different valid domains.
Sapio’s absolute transaction locks cover heights 0..500_000_000 and timestamps
500_000_000..=u32::MAX. Relative heights and time-interval counts fit in a
u16. These types can be passed to the template builder without converting
them into policy clauses.
Miniscript’s typed absolute guards accept encoded values 1..=0x7fff_ffff.
Its relative guards require an enabled BIP68 encoding and a nonzero encoded
operand. For example, a zero-block sequence is a valid transaction field, but
cannot become an Older policy; a timestamp above 0x7fff_ffff also cannot
become an After policy:
#![allow(unused)]
fn main() {
use sapio_base::timelocks::{AbsTime, RelHeight};
use sapio_base::Clause;
assert!(Clause::try_from(RelHeight::from(0u16)).is_err());
let timestamp = AbsTime::try_from(u32::MAX).unwrap();
assert!(Clause::try_from(timestamp).is_err());
}
Use Clause::try_from(lock) or lock.try_into() and propagate
LockTimeError::InvalidPolicyLockTime when building a guard. The Any*TimeLock
wrappers use the same fallible conversion. If constructing Miniscript clauses
directly, use its typed lock constructors; Sapio also rejects the exposed
RelLockTime::ZERO constant during policy validation.
Sats and Coins
Sapio uses integer satoshis for transaction values. At a JSON boundary, the
interface must also make the denomination clear: 10 could otherwise mean
10 satoshis or 10 bitcoin.
Units and serialization
These types serve different purposes:
| Type | Representation and JSON behavior |
|---|---|
u64 | An unsigned integer; the interface must specify its unit. |
i64 | A signed integer; the interface must specify its unit. |
bitcoin::Amount | Unsigned integer satoshis. With Bitcoin’s serde feature, its default JSON representation is an integer number of satoshis. |
bitcoin::SignedAmount | Signed integer satoshis. Use an explicit bitcoin::amount::serde adapter for JSON fields. |
sapio_base::amount::CoinAmount | A tagged input: {"Sats": 1000} or {"Btc": 0.00001}. |
CoinAmount belongs to Sapio, not Bitcoin. Convert it to Amount before using
it in a transaction. The Btc variant uses Amount::from_btc to check the
conversion, including range and fractional-satoshi precision; Sats preserves
the supplied integer exactly.
#![allow(unused)]
fn main() {
use bitcoin::Amount;
use sapio_base::amount::CoinAmount;
let amount = Amount::try_from(CoinAmount::Sats(1000)).unwrap();
assert_eq!(amount.to_sat(), 1000);
assert_eq!(serde_json::to_string(&amount).unwrap(), "1000");
}
A type’s integer range is not a contract budget or Bitcoin’s monetary limit. Validate amounts against the funds available and the rules of the interface.
Binary floating point cannot represent most decimal bitcoin fractions exactly.
Prefer integer satoshis for arithmetic and JSON interfaces you control. In
JavaScript, integers up to 2^53 - 1 are exact, which covers Bitcoin’s maximum
supply expressed in satoshis, but does not cover every u64 value. JSON itself
does not require consumers to use floating point.
Choosing a different wire format
Amount already works in a Vec<Amount> without a custom serializer. An
explicit wrapper is useful when an external interface requires another format,
such as a floating-point number of bitcoin. Its schema must describe that
chosen format too:
#![allow(unused)]
fn main() {
use bitcoin::Amount;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
/// Serialize this interface's amounts as a number of bitcoin.
#[derive(
Serialize, Deserialize, JsonSchema, Clone, Copy, Debug, Ord, PartialOrd, PartialEq, Eq,
)]
#[serde(transparent)]
struct AmountF64(
#[schemars(with = "f64")]
#[serde(with = "bitcoin::amount::serde::as_btc")]
Amount,
);
impl From<Amount> for AmountF64 {
fn from(amount: Amount) -> Self {
Self(amount)
}
}
impl From<AmountF64> for Amount {
fn from(amount: AmountF64) -> Self {
amount.0
}
}
}
Ordinary Amount fields derive an integer-satoshi schema directly through
Sapio’s sapio-jsonschema fork and its bitcoin032 feature. Explicit Serde
adapters still need matching schema annotations: as_sat uses u64, while
as_btc uses f64. Keep the serializer, schema and interface documentation
consistent when choosing a denomination.
Checked arithmetic
Amount arithmetic operators can panic on overflow or underflow. Use
checked_add, checked_sub and the other checked operations when values come
from callers, and propagate an error when a calculation cannot be represented.
Keep calculations in integer satoshis even when the wire format uses bitcoin.
Contract Actions
Contracts have a variety of different actions used at different times.
| name | function |
|---|---|
| guard | Create a clause using miniscript with access to the contract’s values and context. |
| compile_if | Determine if a then or finish should be compiled based on the contract’s values and context |
| then | Create a path or paths that are guaranteed using CTV for a contract to be spent, with optional guards and compile_ifs. |
| continuation | Create a suggested path or paths for a contract to be spent that are not guaranteed via CTV with mandatory guards and compile_ifs. Also accepts an update argument for generating transactions based on future data. |
| decl_*! | For any of the above, declare the existence of a method, for e.g. a trait definition, without defining the function. |
This section will teach you then ins and outs of each.
Guard
A guard is a fixed spending predicate. It can use native Miniscript, a typed program evaluator, or another supported policy compiler.
Inside #[sapio::contract], a #[policy] method declares a reusable predicate.
A #[spend] method additionally exports its predicate as independently sufficient
to spend the output. An action attaches policies with
guarded_by(Self::signed, Self::timeout); these predicates are conjoined.
#![allow(unused)]
fn main() {
#[policy]
fn signed(&self) -> Clause {
Clause::Key(self.owner)
}
}
The ordinary method’s signature expresses its context dependency. With only
&self, it is cached within the compilation. Adding Context evaluates it at
each attachment. The method remains directly callable as Rust.
The standalone #[guard] frontend is useful for trait interfaces and optional
metadata callbacks:
An ordinary guard receives the context of each attachment. Use it when the policy depends on the current path or other compilation context.
A cached guard is evaluated once per guard declaration during a contract’s
compilation. It receives only self, so it cannot accidentally reuse the first
attachment’s context at a different path. Each compilation starts a new cache;
compiling the same contract again evaluates the cached policy again.
guard macro
#![allow(unused)]
fn main() {
#[guard]
fn contextual(self, ctx: Context) {
// Compute a Clause using this attachment's context.
}
#[guard(cached)]
fn signed(self) {
Clause::Key(self.owner)
}
}
These methods belong inside the contract’s implementation. Trait interfaces
can declare their corresponding optional methods with
decl_guard! { contextual } and decl_guard! { cached signed }.
Guard metadata remains contextual even when the policy is cached. A
simps = "Some(Self::metadata)" callback receives its own Context for every
attachment, including standalone finish guards. Its errors abort compilation.
The compiled artifact groups annotations by guard clause and protocol number,
preserving distinct JSON values in attachment order and removing equal values.
Metadata from different attachment paths therefore remains visible without
duplicating identical annotations.
Compile-time action conditions
ConditionallyCompileIf enables a contract writer to evaluate certain
value-based logic before evaluating a path function.
If the return value(s) indicate that a branch should not be evaluated, it is skipped.
When to Use ConditionallyCompileIf
Suppose we’re creating a super secure wallet vault, and we want a recovery path that’s only accessible if the amount of funds being sent to the contract is < an amount.
We could write:
#![allow(unused)]
fn main() {
#[condition]
fn not_too_much(&self, ctx: Context) -> ConditionalCompileType {
if ctx.funds() > Self::MAX_FUNDS {
ConditionalCompileType::Never
} else {
ConditionalCompileType::NoConstraint
}
}
}
Inside #[sapio::contract], apply it with
#[action(committed, compile_if(Self::not_too_much))]. This controls whether the
compiler includes the action. It does not introduce a predicate checked while
spending; spending rules belong in policies.
ConditionalCompileType Variants
There are many different ConditionalCompileType return values:
#![allow(unused)]
fn main() {
pub enum ConditionalCompileType {
/// May proceed without calling this function at all
Skippable,
/// If no errors are returned, and no txtmpls are returned,
/// it is not an error and the branch is pruned.
Nullable,
/// The default condition if no ConditionallyCompileIf function is set, the
/// branch is present and it is required.
Required,
/// This branch must never be used
Never,
/// No Constraint, nothing is changed by this rule
NoConstraint,
/// The branch should always trigger an error, with some reasons
Fail(LinkedList<String>),
}
}
These values are merged according to specific “common sense” logic. Please
see ConditionalCompileType::merge for details.
#![allow(unused)]
fn main() {
/// Fail > non-Fail ==> Fail
/// forall X. X > NoConstraint ==> X
/// Required > {Skippable, Nullable} ==> Required
/// Skippable > Nullable ==> Skippable
/// Never >< Required ==> Fail
/// Never > {Skippable, Nullable} ==> Never
}
Optional interface conditions
The standalone attribute and declaration macro support optional trait methods:
#![allow(unused)]
fn main() {
#[compile_if]
fn available(self, ctx: Context) -> ConditionalCompileType {
ConditionalCompileType::NoConstraint
}
// In a trait interface, its factory is absent unless implemented:
decl_compile_if! { available }
}
Both frontends preserve the same condition algebra. Never and Required
contradict one another; an explicit failure does not hide this contradiction.
An absent condition factory keeps the declared slots of the remaining
conditions, preserving their context paths.
Committed actions
A committed action constructs the transactions permitted by a covenant. Each returned transaction is combined with the action’s authorization guards and the configured covenant lowering. This is useful for fixed payouts, timeout paths, and recursively constructed transaction trees.
#![allow(unused)]
fn main() {
#[sapio::contract]
impl Escrow {
#[action(committed)]
fn refund(&self, ctx: Context) -> Result<Template, CompilationError> {
self.refund_template(ctx)
}
}
}
An argument-free committed action produces its default transaction during compilation. A request-taking committed action requires supplied requests or an explicit default-proposal callback. Its requests participate in compilation of the output’s fixed spending policy: changing them may change the contract’s address.
guarded_by(Self::authorization) attaches fixed policies.
compile_if(Self::availability) applies a separately declared #[condition]
method returning ConditionalCompileType. The normal required action must
produce at least one template; Nullable permits an empty result and Never
omits the action.
A method can return a Template or Result<Template, CompilationError>. Multiple
alternatives are explicit through Vec<Template> or
Result<Vec<Template>, CompilationError>; TxTmplIt remains available for a
fallible stream.
The standalone #[then] frontend remains useful when implementing an optional
trait action declared with decl_then!. It creates the same committed action
representation, with an argument-free default callback. Export it through
declare! {actions, Self::refund}.
Suggested actions
A suggested action constructs candidate transactions under a fixed spending policy. Its authorization guards determine who can spend; returning a candidate from Rust does not commit the output to that transaction.
For example, an escrow’s participants may agree to make a payment and return the remainder to a new escrow. The action provides shared construction logic, while the participants’ signatures authorize the actual transaction.
#![allow(unused)]
fn main() {
#[sapio::contract]
impl Escrow {
#[policy]
fn participants(&self) -> Clause {
Clause::And(vec![Clause::Key(self.alice).into(), Clause::Key(self.bob).into()])
}
#[action(suggested, guarded_by(Self::participants))]
fn pay(&self, ctx: Context, payment: Payment) -> Result<Template, CompilationError> {
self.payment_template(ctx, payment)
}
}
}
Payment is this action’s own request type. The generated
Escrow::pay_action() handle can invoke the method with a typed request or encode
it for compilation through JSON/WASM. There is no contract-wide argument enum.
No request means no invocation. A request-taking action does not need a Default
implementation or an artificial Option parameter. Even a unit request is an
explicit request: JSON null is distinct from an absent effects entry.
When useful, defaults = Self::default_proposals attaches a separate callback
that constructs default candidates. It does not manufacture request arguments.
An argument-free suggested action can use the explicit default flag instead.
The method’s Rust checks validate the generated candidate. Spending requirements belong in its policy or evaluator; the compiler rejects attempts to add new authorization guards through a suggested template.
The standalone #[continuation] frontend remains available for trait interfaces.
It generates the same Action<Self, Request> representation, accepts optional
defaults = "Self::default_proposals", and exposes JSON when marked web_api.
When to use macros
Use #[sapio::contract] on an inherent impl for normal contract authoring. Mark
transaction methods with #[action(committed)] or #[action(suggested)], policies
with #[policy], and independently sufficient spending policies with #[spend].
The methods remain ordinary Rust methods. The macro generates typed action
handles, request schemas and registration.
A policy with only &self is context-free and cached. A policy that also accepts
Context is evaluated at its attachment context. The signature expresses the
actual dependency.
The explicit low-level API is Action<Contract, Request>. Its constructor takes a
transaction-generation callback. with_guards, with_conditions, with_json,
and with_defaults add the corresponding capabilities; erase() hides only the
individual request type when registering the action. There is no global argument
pack. This API is useful for dynamically assembled contracts.
Optional interfaces can still use decl_then!, decl_continuation!, and
decl_guard!, implemented by the standalone action/guard attributes. A factory
returning None means that the implementation does not provide that interface
member. Export optional factories explicitly through declare! {actions, ...} or
#[sapio::contract(actions(Self::optional), spends(Self::optional_guard))].
Use #[condition] with compile_if(...) for availability depending on the
contract’s value. Its condition algebra distinguishes absent, nullable and
required branches; it does not add a spending predicate.
Contract declarations
#[sapio::contract] registers explicitly marked actions and spending policies.
Ordinary helper methods remain ordinary helpers. #[policy] declares a policy
that can guard actions; #[spend] also exports it as independently sufficient to
spend the output.
Contract hooks are ordinary methods marked #[amount] for minimum funding,
#[internal_key] for an already authorized Taproot internal key, and #[metadata]
for descriptive object metadata. Selecting an internal key never grants new
spending authority.
An explicit Contract implementation can register factories directly:
#![allow(unused)]
fn main() {
impl Contract for Escrow {
declare! {actions, Self::refund, Self::propose_payment}
declare! {finish, Self::cooperative}
}
}
Each action has its own request type before registration. A factory returns
Option<Box<dyn ErasedAction<Self>>>; returning None explicitly omits an
optional interface member. Action names must be unique within a contract so typed
request paths cannot be silently redirected.
DynamicContract<S> assembles actions and independent finish factories in
vectors alongside the contract data, metadata callback and minimum-funding
callback. A custom AnyContract implementation can provide the same compiler
interface without choosing a specific storage layout.
Existing addresses can be used through Compiled::from_address. They provide an
output destination and minimum-funding information, but no action API or source
policy beyond the supplied artifact.
Contract Compilation Overview
When the compiler sees a new contract, it proceeds by processing each path item one at a time. If the order of compilation is important for your contract:
- reconsider your priorities
- repeat step 1
- read the logic inside of the
Compilable::compilefunction
This logic may be improved over time to take advantage of parallelization or otherwise restructure. As such, one should be careful when switching compiler versions. Further, optimizers or data structures may be unstable with respect to things like renamed functions leading to changes of compilation result.
Determinism?
Sapio is designed to be determinism-friendly. Repeated runs of the same program should – unless the user includes entropy – return the same results.
However, at writing, this property is not closely audited for, so outputs should be treated as required to be stored in order to use a contract.
On the other hand, determinism means that for multi-party contracts being generated in a Replicated state machine, if all parties have the same e.g. WASM plugin, they can generate a contract definition and check that the merkle root (in this case, a bitcoin address) is the same. If it differs, either the arguments differed, someone cheated, or there was unexpected non-determinism.
Typed action requests and effects
A contract’s spending policy can be compiled without making a request to every action. Each suggested action publishes its own argument schema and effects path. The generated typed handle encodes a request at that exact path:
#![allow(unused)]
fn main() {
let effects = Escrow::pay_action().request(&root_path, &payment)?;
let context = Context::new(
network,
amount,
lowering,
root_path,
Arc::new(effects),
None,
);
let artifact = escrow.compile(context)?;
}
The request invokes only pay; it is not passed through unrelated actions or a
shared contract-wide enum. JSON deserialization occurs at the request boundary.
Escrow::pay_action().invoke(&escrow, context, payment) is the equivalent typed
Rust construction entry point. Directly calling escrow.pay(context, payment)
also works because the contract macro preserves ordinary methods.
For several candidates at the same action, use
Escrow::pay_action().requests(&root_path, &payments). Entries receive distinct,
deterministic labels that preserve their input order. An empty collection adds no
requests. The lower-level MapEffectDB remains available when requests must be
attached at multiple contract paths.
An argument-free suggested action still needs an explicit unit request, encoded
as JSON null. An absent entry never means “call this action with a default
value.” Explicit default-proposal callbacks are evaluated separately.
Suggested transactions remain subject to their fixed authorization policy. A candidate does not acquire authority because its generator accepted it. For example, an NFT sale generator may construct the transfer and seller payment; the owner must still authorize the resulting transaction through the spending policy. A committed action additionally fixes its candidate transactions in the compiled covenant, so changing committed candidates may change the address.
Raw effects paths that are not visited by compilation are not automatically reported as unused. Typed handles avoid hand-written action paths; callers that assemble raw effects maps remain responsible for targeting the intended contract instance.
Sapio for Fun (and Profit)
In this section, we’re going to build a simple option contract. This sort of contract could be used, for example, to make an on-chain asynchronous offer to someone to enter a bet with you.
Then, you’ll have some challenges to modify the contract to extend it’s functionality meaningfully.
The logic for the basic contract is as follows:
- If \(\tau_{now} > \tau_{timeout} \):
- send funds to return address
- If
strike_pricebtc are added:- send funds +
strike_priceto strike_into contract
- send funds +
#![allow(unused)]
fn main() {
/// The Data Fields required to create a on-chain bet
pub struct UnderFundedExpiringOption {
/// How much money has to be paid to strike the contract
strike_price: Amount,
/// if the contract expires, where to return the money
return_address: bitcoin::Address,
/// if the contract strikes, where to send the money
strike_into: Box<dyn Compilable>,
/// the timeout (as an absolute time) when the contract should end.
timeout: AnyAbsTimeLock,
}
impl UnderFundedExpiringOption {
#[then]
/// return the funds on expiry
fn expires(self, ctx: Context) {
ctx.template()
// set the timeout for this path -- because it is using
// a committed action needs no separate timelock guard.
.set_lock_time(self.timeout)?
.add_output(
// ctx.funds() knows how much money has been sent to this contract
ctx.funds(),
// this bootstraps an address into a contract object
&Compiled::from_address(self.return_address.clone(), None),
None,
)?
.into()
}
/// continue the contract
#[then]
fn strikes(self, ctx: Context) {
let tmpl = ctx.template().add_amount(self.strike_price);
let amt = (tmpl.ctx().funds() + self.strike_price).into();
tmpl.add_sequence()
.add_output(
// use the inner context of tmpl because it has added funds
amt,
self.strike_into.as_ref(),
None,
)?
.into()
}
}
impl Contract for UnderFundedExpiringOption {
declare!(actions, Self::expires, Self::strikes);
}
}
Challenges
There’s no right answer to the following challenges, and the resulting contract may not be too useful, but it should be a good exercise to learn more about writing Sapio contracts.
- Clear out your helloworld plugin and put this code in.
- Write a contract designed to be put into the
strike_intofield which sends funds to one party or the other based on a third-party revealing a hash preimageAorB. - Modify the contract so that there is a
expire_Aand aexpire_Bpath that go to different addresses, andexpire_Arequires a signature or hash reveal to be taken. - Modify the contract so that if
expire_Ais taken, a small payoutearly_exit_fee: bitcoin::Addressis made to aearly_exit : bitcoin::Address. - Modify the contract so that
expire_Ais only present the fields required by it areOption::is_some(hint: usecompile_if!). - Add logic to deduct fees.
- Add a
cooperative_closeguard!clause that allows both parties to exit gracefully
Limitations of Sapio
Sapio is rapidly maturing, but it is still early-days for bitcoin smart contract compilers, and early-days for Sapio in particular. As such, one should be careful to closely audit smart contracts designed with Sapio, be careful to only use such contracts with trusted inputs, and in general take precautions to ensure security of funds. As Sapio matures and we gain confidence in smart contracts built with it, Sapio should be able to greatly improve the security for many bitcoin users and applications.
Other than these general “alpha software” disclaimers, Sapio is designed with certain upgrades to Bitcoin in mind that have not yet landed. While Sapio is designed to work even without these upgrades, the functionality is severely reduced or has a different security model.
This section goes over some of these limitations and when we might expect to see them addressed.
BIP-119 Emulation
This chapter describes the older CTV-specific signer model. The maintained starter uses the separate, general WASM-evaluated program protocol; see program emulation for current program identities, signing requests and execution limits.
Changes to Bitcoin take a long time. The star player in making Sapio work is BIP-119, and that might take a while to get merged. To get around this, Sapio provides some tools to enable similar functionality today by emulating BIP-119 with signatures.
Compilation inputs and runtime signers
Compilation takes a public LoweringPlan containing either Native or
CtvEmulation { signers, threshold }. The signer entries are extended public
keys. Only explicit Emulatable(Ctv(hash)) predicates follow that plan;
ordinary native CTV clauses and raw scripts retain their meaning. Transaction
actions add an emulatable CTV predicate automatically.
Module arguments must include context.lowering. Nested modules receive the
same explicit inputs, and compilation does not connect to a signer or ask a
host to choose a policy. WASM compilation hosts expose no signing capability.
The CTV emulator implementations provide runtime signing services. Binding checks that the configured runtime signer reproduces the policies selected by the artifact’s recorded public inputs. Keeping the seed requires trusting its custodian; deleting it requires collecting every signature that will be needed first. Public lowering inputs alone do not establish availability or key deletion.
This crate also defines logic for servers that want to offer emulator services to binding and signing clients. This is convenient since the emulator server must be kept secure, so an organization may want it to be more tightly safeguarded.
The emulator definitions include wrapper types that compose individual instances of an emulator into a federated multisig. This is useful for circumstances where a contract is between e.g. 2 parties and both have an emulator server. Then the contract can be “immutable” unless both collude.
Configure your own runtime signers separately from the compilation inputs. The CLI requires an explicit signer or native research mode; a missing or disabled emulator configuration no longer selects native CTV implicitly.
How it works
See the source code for more detailed documentation.
CheckTemplateVerify essentially functions as a self-signed transaction. I.e., imagine you could create a public key that could only ever sign a transaction which matched a certain pattern?
To implement this functionality, we use BIP-32 HD keys with public derivation.
On initialization, a server picks a seed S and generates a root public key K from it, and publishes K.
Users generate a transaction T and extract the CheckTemplateVerify hash H for
it. They then take H and convert it into a derivation path D of 8 u32’s and 1
u8 for non-hardened derivation (see hash_to_child_vec).
This derivation path is then applied to K to generate a key C. This key is added with a CheckSig(SIGHASH_ALL) to the script in place of a CTV clause.
Then, when a user desires to spend an output with such a key, they create the entire transaction they want to occur and send it to the emulator server.
Without even checking to see that the key is used in the transaction, the server generates the template hash H’ (which should equal H) and then signs, returning the signature to the client.
Before creating a contract, clients may wish to collect all possible signatures required to prevent an availability fault.
This scheme has the benefit that:
- contract specification can occur without any online processes
- The server has no intelligent logic, all guarantees are structural.
- Server is completely stateless.
- Availability/malfeasance can be controlled for with multisig
- 1:1 functionality mapping to CTV
The downside of this approach to emulation is that:
- It is somewhat inefficient for scripts which have many branched possibilities.
- No inherent mechanism to delete keys after use to protect against future exfiltration.
Why BIP-32
We use BIP-32 because it is a well studied primitive and derivation paths are compatible with existing signing hardware. While it is true that a tweak of 32 bytes could be directly applied to the key more efficiently, easier interoperability with existing tools seemed to be the best path.
Customizing Emulator Trait
The emulator trait belongs to binding and signing. get_signer_for advertises
the runtime’s policy for compatibility checks, and sign supplies signatures.
Compilation derives policies locally from LoweringPlan and never invokes
this trait. A custom implementation does not add support for arbitrary
encumbrance programs; those require their own defined evaluator and protocol.
As a user of the Sapio library, you can define your own custom emulator logic but that’s out of scope of this book.
Future Work
There is a plan to make emulation more efficient based on Merkelization, but it is not yet implemented because it messes with the current way the compiler works.
The efficiency issues are also solvable, more or less, with taproot.
Taproot
Sapio contract logic can become very large in size, so Sapio benefits from being able to split up and merkelize the logic into smaller satisfiable chunks. This makes it much more economical and easy to use Sapio however you like.
Generally speaking, a Sapio programmer need not think about this too much, it will be set up automatically under the hood. However, at writing, limited optimizing of Taproot trees is done, so a wise programmer would want to express their program in such a way to not allow Taproot leafs to be larger than need be.
Advanced Transaction Handling
Sapio does not try to handle all possible types of Bitcoin transactions.
There are certain “advanced techniques” that have use cases, but are
difficult to reason about. For example, there are many ways that SIGHASH
flags can be exploited to create all sorts of possibilities. You can use
OP_2DUP OP_SHA256 <H1> OP_EQUALVERIFY OP_SWAP OP_SHA256 <H2> OP_EQUALVERIFY OP_SIZE OP_SWAP OP_SIZE OP_EQUAL (or something similar) to flip a fair coin between participants. There is a lot.
But Sapio doesn’t make an effort to cleanly handle all possible contracts. It makes an effort to address a safe and useful subset and make those contracts well integrated with other standard software.
If you identify a killer use-case contract, please open an issue or a PR to discuss the new functionality and how to add it.
Mempool & Fees
The Mempool is a treacherous place. If you’re not familiar, the Mempool is Bitcoin’s backlog of unconfirmed transactions. It is a bounded queue which makes a best effort at storing transactions that pay higher fees and dropping transactions which pay insufficient fees.
The Mempool is an issue for a Sapio user because Sapio contracts are generally immutable, which implies that Sapio contracts have to estimate the minimum feerates at the time of contract creation.
For example, suppose I make a contract that has a state transition paying a 200 sats per vbyte feerate. And then by the time that transaction reaches the mempool, it has gone up to 201 sats per vbyte minimum. Now I cannot easily broadcast my transaction, and it is unlikely to wind up in a block.
There are many other ways that transactions can end up stuck.
Fortunately, there are some solutions to these sorts of problems, but none of them are exactly “easy”. We’ll divide them in three categories:
Careful Contract Programming
Careful contract programming can ensure that:
- All contract transitions pay a high enough minimum we expect to be able to get into the mempool in the future
- There are ways to inject “gas inputs” into the contract, if needed
- There are ways to spend “gas outputs” from the contract just for Child-Pays-For-Parent logic.
- Relative timelocks are used to prevent pinning attacks
For a discussion of this topic with visuals, please see the Sapio Reckless VR Talk section on fees:
TODO: Integrate this content into writing
P2P Network/Mempool Policy Changes
Package Relay is a proposed technique that is progressing for Bitcoin whereby multiple transactions can be submitted in one bundle to show suitability for the mempool. Therefore a contract leaf node might be able to demonstrate, by spending the coin, that the contract interior nodes are worth mining.
However, this technique is limited insofar as contract interior nodes in Sapio may commonly have relative time locks (or similar) which prevent the mempool from considering dependents.
Package Relaying does, however, improve the function of intentional gas outputs.
Consensus Changes
Consensus changes are very difficult to create, but it’s possible that in the future some set of consensus changes help decouple contract execution from fee paying.
For example, there is a proposal to replace Replace-By-Fee and Child-Pays-For-Parent with a mechanism that functions as a virtual CPFP link. However, such proposals can introduce subtle changes to Bitcoin’s behavior and must be vetted closely.
Application Packaging
So you’ve written a Sapio contract and you’re ready to get it out into the world.
How should you release it? How should you use it?
This section covers various ways to deploy and use Sapio contracts.
In general, it is important to make the code available in an open source way, so others can integrate and use your contracts. Rust’s crates system provides a natural place to publish for the time being, although in the future we may build a Sapio specific package manager as smart contracts have some unique differences.
WASM
This reference chapter retains illustrative historical snippets. For maintained compiler guests and runnable commands, use the repository’s development guide and example catalog. The starter’s included WASM payment evaluator is distinct from a compiler plugin.
WASM is “WebAssembly”, or a standard for producing bytecode objects that can be run on any platform. As the name suggests, it was originally designed for use in web browsers as a compiler target for any language to produce code to run safely from untrusted sources.
So what’s it doing in Sapio?
WASM is designed to be cross platform and deterministic, which makes it a great target for smart contracts that we want to be able to be reproduced locally. Sapio validates guest memory access and applies execution fuel, memory/table caps and nested-call limits before running a module. These bounds cover guest execution; native compilation and external services need their own resource policy. Loading a module is not a guarantee that its contract is safe or that its intended covenant is enforced on the selected chain.
Sapio Contract objects can be built into WASM binaries very easily. The code required is basically:
#![allow(unused)]
fn main() {
/// MyContract must support Deserialize and JsonSchema
#[derive(Deserialize, JsonSchema)]
struct MyContract;
impl Contract for MyContract{\*...*\};
/// binds to the plugin interface -- only one REGISTER macro permitted per project
REGISTER![MyContract];
}
See the example for more details.
These compiled objects require a special environment to be interacted with.
That environment is provided by the Sapio CLI as a
standalone binary. It is also possible to use the interface provided by the
sapio-wasm-plugin crate to load a plugin from your rust codebase
programmatically. Lastly, one could create similar bindings for another
platform as long as a WASM interpreter is available.
Cross Module Calls
The WASM Plugin Handle architecture permits one WASM plugin to call into another. This is incredibly powerful. What this enables one to do is to package Sapio contracts that are generic and can call one another either by hash (with effective subresource integrity) or by a nickname (providing easy user customizability).
For example, suppose I was writing a standard contract component C which I
publish. Then later, I develop a contract B which is designed to work with
C. Rather than having to depend on C’s source code (which I may not want
to do for various reasons), I could simply hard code C’s hash into B and
call create_contract_by_key(key: &[u8; 32], args: Value, amt: Amount) to
get the desired code. The plugin management system automatically searches for
a contract plugin with that hash, and tries to call it with the provided JSON
arguments. Using create_contract(key:&str, args:Value: amt:Amount), a
nickname can be provided in which case the appropriate plugin is resolved by
the environment.
#![allow(unused)]
fn main() {
struct C;
const DEPENDS_ON_MODULE : [u8; 32] = [0;32];
impl Contract for C {
#[then]
fn demo(self, ctx: Context) {
let amt = ctx.funds()/2;
ctx.template()
.add_output(amt, &create_contract("users_cold_storage", /**/, amt), None)?
.add_output(amt, &create_contract(&DEPENDS_ON_MODULE, /**/, amt), None)?
.into()
}
}
}
Typed Calls
SapioHostAPI<T, R> resolves a module locator to a key and provides typed calls.
Arguments T implement Serialize, JsonSchema, and Clone; results R
implement Deserialize and JsonSchema. Resolving the locator makes no claim
that every value of T is accepted by that module.
Plugin APIs explicitly generate JSON Schema Draft 7 for both arguments and
results. The host accepts that dialect and applies bounded, offline validation.
The sapio-jsonschema fork retains the schemars package name and derive
attributes, with opt-in implementations for Bitcoin and Miniscript types.
The native host validates each actual CreateArgs<T> input against the module’s
advertised input schema before calling its create function. It validates each
successful result against the advertised output schema before returning it,
then the caller deserializes that result as R. Ordinary module errors remain
errors. These checks enforce JSON constraints for that call; contract behavior
and compatibility between whole interfaces require their own specifications.
Versioned enum variants identify shared calling conventions. For example, the batching interface defines its arguments as follows:
#![allow(unused)]
fn main() {
/// A payment to a specific address
#[derive(JsonSchema, Serialize, Deserialize, Clone)]
pub struct Payment {
/// The amount to send
#[serde(with = "bitcoin::amount::serde::as_btc")]
#[schemars(with = "f64")]
pub amount: bitcoin::Amount,
/// # Address
/// The Address to send to
pub address: bitcoin::Address<bitcoin::address::NetworkUnchecked>,
}
#[derive(Serialize, JsonSchema, Deserialize, Clone)]
pub struct BatchingTraitVersion0_1_1 {
pub payments: Vec<Payment>,
#[serde(with = "bitcoin::amount::serde::as_sat")]
#[schemars(with = "u64")]
pub feerate_per_byte: bitcoin::Amount,
}
}
The payment amount deliberately uses bitcoin units through as_btc; the fee
rate uses integer satoshis through as_sat. Upstream Amount defaults to
integer-satoshi JSON, and the explicit schema annotations describe this
interface’s chosen representation.
Addresses received from JSON use Address<NetworkUnchecked> and a string
schema. A receiving contract must check the address against its compilation
network before using it as an output, for example:
#![allow(unused)]
fn main() {
let address = payment.address.clone().require_network(ctx.network)?;
let destination = Compiled::from_address(address, bitcoin::Amount::ZERO);
}
The string schema describes the wire format; parsing and require_network
enforce address validity and the selected network. Already checked Address
values are used after that boundary.
The shared interface wraps those arguments in a versioned variant:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, JsonSchema, Clone)]
pub enum Versions {
BatchingTraitVersion0_1_1(BatchingTraitVersion0_1_1),
}
pub type BatchingModule = ContractModule<Versions>;
}
ContractModule<Versions> is a SapioHostAPI whose result is a compiled contract.
The serialized arguments keep the version tag:
{"BatchingTraitVersion0_1_1":{"payments":[],"feerate_per_byte":0}}
The treepay example accepts this variant alongside its direct TreePay and
Advanced variants. A receiver can therefore offer additional calling
conventions while accepting the shared batching input. Its schema is checked
against the actual call rather than compared for equality with the caller’s
schema.
Future Work on Cross Module Calls
- Gitian Packaging: Using a gitian signed packaging distribution system would enable a user to set up a web-of-trust setting for their sapio compiler and enable fetching of sub-resources by hash if they’ve been signed by the appropriate parties.
- NameSpace Registration: A system to allow people to register names unambiguously would aid in ensuring no conflicts. For now, we can handle this using a centralized repo.
- Remote CMC: In some cases, we may want to make a call to a remote server that will call a given module for us. This might be desirable if the server holds sensitive material that we shouldn’t have.
Rust Lib/Bin
There’s not much to be said here. Sapio code is just Rust code, so it can be shipped as a standalone rust library or binary tool.
This code can then be integrated into any codebase either natively or using FFI.
It’s a good idea to always package contracts as a library separate from the binary, so that if a user wants to natively incorporate the contract it is easy to do, and the packaged WASM or binary can be a utility based on it.
Sapio Studio
Sapio Studio is an in-development graphical user interface for Sapio.
Studio loads WASM modules and their argument/result schemas, connects compatible value ports in a visual patch, and compiles the selected terminal module. These wires describe how to construct public contract terms. The resulting transaction graph is a separate view of what the compiled contract can spend to.
The build-a-vault tutorial provides ten small blocks and five executable patches. Start with signers, destinations and a block delay; combine them into recovery and release rules; then compile a fixed vault, a delayed wallet, or a dynamic OP_VAULT emulator. The tutorial covers changing wires, inspecting the compiled transaction graph, saving patches, and preparing the public evidence needed for signing.
Intermediate module results should use small, nonrecursive public data types.
The vault example shares the same Rust DTO between each output and compatible
input so schema matching stays precise. Terminal modules return Compiled;
private signing material stays outside the patch.
Sapio Command Line Interface
The maintained starter walkthrough uses the CLI to inspect an artifact, prepare one branch, export an explicit program request, import its verified response and complete the retained witness. See the generated project README and the current CLI guide.
From the Sapio repository root, inspect the available commands with:
cargo run --locked -p sapio-cli -- --help
cargo run --locked -p sapio-cli -- contract spend --help
Artifact inspection and selected-spend commands use local files without loading
wallet or network configuration. signer program uses an explicitly supplied
key, request and evaluator. It does not discover oracles or register code from
artifact metadata.
Other commands support compiler plugins, binding and emulator services. Those workflows have their own configuration and enforcement assumptions; follow the current CLI guide rather than older Studio or container setup instructions.
Advanced Rust Patterns
Say it with me – Sapio’s Just Rust ™. Even though there’s a lot of additional paradigms and information to take in to use Sapio over normal Rust programming, at the end of the day you can integrate Sapio into any Rust paradigm you like.
That said, this section has a few useful patterns that merit specific mention as you may find yourself reaching for them again and again.
Type-level state machines
Rust types can express which actions a contract implementation provides. An
optional action factory returns None when a transition is unavailable; this is
separate from an action returning an empty set of transactions.
The following sketch uses Opened and Closed as state tags. Both states retain
the owner’s independent spending policy, while only the open state exports the
committed close transition.
#![allow(unused)]
fn main() {
use std::marker::PhantomData;
struct Opened;
struct Closed;
struct StatefulContract<State> {
owner: bitcoin::XOnlyPublicKey,
state: PhantomData<State>,
}
trait Moves: Sized + 'static {
sapio::decl_then! { close }
}
impl Moves for StatefulContract<Opened> {
#[sapio::then]
fn close(self, ctx: Context) {
let amount = ctx.funds();
ctx.template()
.add_output(amount, &StatefulContract::<Closed> {
owner: self.owner,
state: PhantomData,
}, None)?
.into()
}
}
impl Moves for StatefulContract<Closed> {}
#[sapio::contract(actions(Self::close))]
impl<State: 'static> StatefulContract<State>
where
Self: Moves,
{
#[spend]
fn owner(&self) -> Clause {
Clause::Key(self.owner)
}
}
}
decl_then! supplies an absent default factory. The open implementation replaces
it; the closed implementation keeps it absent. The contract macro explicitly
exports that optional interface and the independent owner policy.
For availability that depends on a value rather than a Rust type, use a
#[condition] method with compile_if(...). For example, an eltoo state can omit
its update action after reaching the maximum state number. Required and
Nullable still distinguish an action that must generate a transaction from one
that may legitimately return none.
Rust enums, traits, generics and const generics can organize more elaborate state machines. They execute while constructing and compiling contracts. An ordinary Rust state check is not automatically enforced by a spending script: the committed transition or fixed policy/evaluator must enforce the corresponding on-chain rule.
TryFrom Constructors
Often times we want to assure that various properties must be true about the arguments passed to a contract instance.
By using TryFrom and being careful with the visibility of inner fields it is possible to guarantee that the only way to get an X is by going through type Y.
This can be bound using the serde(try_from) attribute, which makes it so
that any deserialization of X first passes through Y. This is
particularly useful when X contains types (such as function pointers or
caches) that cannot be deserialized, but we want to provide a way for a third
party to pass JSON args to construct an X.
#![allow(unused)]
fn main() {
use std::convert::TryFrom;
use std::convert::TryInto;
use serde::*;
/// inner argument not pub, X cannot be constructed without going through Y
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(try_from="Y")]
pub struct X(u32);
#[derive(Serialize, Deserialize, JsonSchema)]
pub struct Y(pub u32);
impl TryFrom<Y> for X {
type Error = &'static str;
fn try_from(y: Y) -> Result<Self, Self::Error> {
if y.0 < 10 {
Err("Too Small I Guess?")
} else {
Ok(X(y.0))
}
}
}
let x: X = Y(10).try_into().unwrap();
}
Concrete & Generic Types
Generics
Often time, it can be useful to make a generic contract, such as:
#![allow(unused)]
fn main() {
struct GenericA {
send_to: Box<dyn Compilable>
}
}
or
#![allow(unused)]
fn main() {
struct GenericB<T:Compilable> {
send_to: T
}
}
In GenericA we use a trait object to allow us to let the send_to field
equal any Compilable type while having the same type GenericA,
whereas GenericB takes a type parameter that makes the GenericB more
specifically typed.
To highlight the differences between the approaches, suppose I had a parent contract:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, JsonSchema)]
struct ConcreteA;
#[derive(Serialize, Deserialize, JsonSchema)]
struct ConcreteB;
struct AliceAndBobFree {
alice: GenericA;
bob: GenericA;
}
/// inner types can differ
let example_free_ok = AliceAndBobFree { alice: GenericA{send_to: Box::new(ConcreteA)},
bob: GenericA{send_to:Box::new(ConcreteB)}};
struct AliceAndBobRestricted<T> {
alice: GenericB<T>;
bob: GenericB<T>;
}
/// inner types cannot differ
let example_restricted_fails = AliceAndBobRestricted { alice: GenericB{send_to: ConcreteA},
bob: GenericB{send_to: ConcreteB}};
}
It might seem like you always want to use the GenericA variant, but there are cases where you
might want to guarantee that Alice and Bob’s supplied contracts are the same type.
Concrete Wrappers
When you do have a generic type (either with trait objects or otherwise) it
can be difficult to use across an application boundary. To get around this,
one can create a wrapper type (or enum) that uses the TryFrom
paradigm to provide paths for the type to be concrete. E.g.,
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, JsonSchema)]
enum Concrete {
A(ConcreteA),
B(ConcreteB),
}
impl TryFrom<Concrete> for GenericA {
type Error = &'static str;
fn try_from(concrete:Concrete) -> Result<Self, Self::Error> {
match concrete {
Concrete::A(a) => GenericA(Box::new(a)),
Concrete::B(b) => GenericA(Box::new(b))
}
}
}
}
Thus a Concrete can be used in a Serialize/Deserialize/JsonSchema API bound
context, whereas a GenericA could not.