The Sapio Language
Designing Bitcoin Contracts with Sapio
A practical guide to engineering bitcoin smart contracts using the Sapio Language.
This book is a work in progress! Please submit a PR with improvements or suggestions.
Introduction
Welcome to Designing Bitcoin Contracts with Sapio, the official manual and best starting place to learn how to make Smart Contracts for Bitcoin. Sapio is an in-development tool that empowers Bitcoin Developers to craft smart contracts in an intuitive, safe, and composable way. Sapio challenges the notion that you can’t make complex smart contracts for Bitcoin, and opens the floodgates for a myriad of new ideas to be defined easily.
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.
Sapio is currently alpha quality software. You should think very carefully before using Sapio with any real money. There will be kinks to untwist, wrinkles to iron out, and bugs to squash. Hopefully you, dear reader, will even be able to help with that! Sapio is not – at present – for the faint of heart.
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
Let’s start buil… not so fast there.
Before we get into it, we need to cover some basics:
- Setting up an environment
- Learning Rust
- Hello World contract
Installing Sapio
Sapio Pod QuickStart:
Today, Sapio can come to you in an easy to set up Docker compatible container (unofficial™). With the Sapio pod you get:
- A CTV Compatible Bitcoin Node running regtest
- Rust
- A pre-built cached Sapio Directory for you to use as a workspace
- sapio-cli pre-built
- Sapio Studio built and running over X11 connected to your regtest node
- neovim for editing
See the repo for setup instructions, especially with x11 through containerization.
This is the simplest way to get a working Sapio playground, but you may prefer to have it set up locally (x11 can be glitchy). The Sapio Pod is currently targetted at someone wanting a pain free development environment for tutorials, but future releases may target more specific needs such as deployments in infrastructure.
The book will assume this is your setup, and instructions will be tailored appropriately.
Local QuickStart:
Sapio should work on all platforms, but is recommended for use with Linux (Ubuntu preferred). Follow this quickstart guide to get going.
- Get rust if you don’t have it already.
- Add the wasm target and nightly toolchain by running the below command in your terminal:
rustup target add wasm32-unknown-unknown
Tip: On macOS you may need to do the following:
brew install llvm cargo install wasm-pack rustup toolchain install nightly rustup default nightlyand then load the following before compiling to use the newer llvm/clang.
export PATH="/opt/homebrew/opt/llvm/bin:$PATH" # for older homebrew installs # export PATH="/usr/local/opt/llvm/bin:$PATH" export CC=/opt/homebrew/opt/llvm/bin/clang export AR=/opt/homebrew/opt/llvm/bin/llvm-ar
- Clone this repo:
git clone --depth 1 git@github.com:sapio-lang/sapio.git && cd sapio
We recommend a shallow clone unless you want the full history.
- Build a plugin
cd plugin-example/ && cargo build --release --target wasm32-unknown-unknown && cd ..
If the compilation fails, you may want to check the clang version (8<=), and install libraries for cross-compilation (in case of ubuntu, sudo apt install gcc-multilib)
- Instantiate a contract from the plugin:
cargo run --bin sapio-cli -- contract create "{\"arguments\":{\"ForAddress\":{\"amount_step\":{\"Sats\":100},\"cold_storage\":\"bcrt1qumrrqgt7e3a7damzm8x97m6sjs20u8hjw2hcjj\",\"hot_storage\":\"bcrt1qumrrqgt7e3a7damzm8x97m6sjs20u8hjw2hcjj\",\"mature\":{\"RH\":10},\"n_steps\":10,\"timeout\":{\"RH\":5}}},\"context\":{\"amount\":1000,\"network\":\"Regtest\"}}" --file="plugin-example/target/wasm32-unknown-unknown/debug/sapio_wasm_vault.wasm"
You can use cargo run --release --bin sapio-cli -- help to learn more about what a the
CLI can do! and cargo run --bin sapio-cli -- <subcommand> help to learn about
subcommands like contract. If you aren’t modifying Sapio itself, you’ll want
to run cargo build --release and use a release binary as it is much faster.
- Install Sapio Studio
Sapio Studio is an in-development graphical user interface for Sapio. It is the recommended way to get started with Sapio development. We recommend a shallow clone unless you want the full history.
git clone --depth 1 git@github.com:sapio-lang/sapio-studio.git && cd sapio-studio
yarn install
and then in separate shells
yarn start-react
yarn start-electron
The first time you run it you most likely will have some errors, you will need to ensure you’ve configured your client correctly. You can do this by opening the Preferences menu and configuring it appropriately. Soon there will be a better interface for first run setup.
Docs
You can review the docs either by building them locally or viewing online.
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.
Hello World
Let’s get going with your very first hello world contract!
Unfortunately, until Sapio becomes a little more popular the embedded rust playground won’t work, so you’ll want to copy it locally.
We’re going to start with a contract that allows two parties, Alice and Bob, to either agree on an outcome or to default to a pre-fixed outcome after a relative timeout.
#![allow(unused)]
fn main() {
//! Hello World Contract
#![deny(missing_docs)]
#[cfg(target_arch = "wasm32")]
use sapio_wasm_plugin::{optional_logo, REGISTER};
use sapio::contract::*;
use sapio::*;
use sapio_base::amount::CoinAmount;
use sapio_base::timelocks::RelTime;
use sapio_base::Clause;
use schemars::JsonSchema;
use serde::Deserialize;
use std::convert::{TryFrom, TryInto};
/// Trustless Escrow Contract
#[derive(JsonSchema, Deserialize)]
pub struct TrustlessEscrow {
alice: bitcoin::XOnlyPublicKey,
bob: bitcoin::XOnlyPublicKey,
alice_escrow_address: bitcoin::Address<bitcoin::address::NetworkUnchecked>,
alice_escrow_amount: CoinAmount,
bob_escrow_address: bitcoin::Address<bitcoin::address::NetworkUnchecked>,
bob_escrow_amount: CoinAmount,
}
impl TrustlessEscrow {
#[guard]
fn cooperate(self, _ctx: Context) {
Clause::And(vec![
Clause::Key(self.alice).into(),
Clause::Key(self.bob).into(),
])
}
#[then]
fn use_escrow(self, ctx: Context) {
let network = ctx.network;
ctx.template()
.add_output(
self.alice_escrow_amount.try_into()?,
&Compiled::from_address(
self.alice_escrow_address.clone().require_network(network)?,
bitcoin::Amount::ZERO,
),
None,
)?
.add_output(
self.bob_escrow_amount.try_into()?,
&Compiled::from_address(
self.bob_escrow_address.clone().require_network(network)?,
bitcoin::Amount::ZERO,
),
None,
)?
.set_sequence(
0,
RelTime::try_from(std::time::Duration::from_secs(10 * 24 * 60 * 60))?.into(),
)?
.into()
}
}
impl Contract for TrustlessEscrow {
declare! {finish, Self::cooperate}
declare! {actions, Self::use_escrow}
}
#[cfg(target_arch = "wasm32")]
REGISTER![TrustlessEscrow, "logo.png"];
}
The implementation is in plugin-example/helloworld/src/plugin.rs. Its
JSON addresses deserialize as Address<NetworkUnchecked> and are checked
against ctx.network before becoming outputs. The fixed payout branch uses
Sapio’s selected covenant backend; signer emulation includes trust in its
signers.
From the repository root, build this plugin with:
cargo build --manifest-path plugin-example/Cargo.toml \
--package sapio-wasm-helloworld --release \
--target wasm32-unknown-unknown --locked
Use the repository’s pinned Rust toolchain and an LLVM Clang that supports
wasm32 for secp256k1’s C code. On Linux, set
export CC_wasm32_unknown_unknown=clang; on macOS with Homebrew LLVM installed, set
export CC_wasm32_unknown_unknown="$(brew --prefix llvm)/bin/clang".
The default output is
plugin-example/target/wasm32-unknown-unknown/release/sapio_wasm_helloworld.wasm
(unless you override Cargo’s target directory).
Challenges
For the challenges, you’ll want to modify the helloworld plugin file directly. Through this tutorial we’ll use this as a sandbox file.
- Add a new finish state that allows Alice to spend after a relative timeout.
- Add
use_escrow2which enables a different pair of payouts to Alice and Bob as an alternative.
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
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
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.
Currently, Sapio Studio works based on managing a WASM plugin directory, so that users can more readily add contracts of their choosing.
Contracts packaged for WASM have some additional constraints or functionality for aiding in the generation of a UX.
Sapio Command Line Interface (CLI)
The Sapio CLI (or sapio-cli) is rapidly changing, but it is self
documenting using cargo run sapio-cli help.
sapio-cli aids users in:
- compiling sapio contracts into templates
- binding compiled templates to specific utxos from your bitcoin wallet
- inspecting contract plugins
- running emulator servers
sapio-cli has a config file (location dependent on platform, under
org.judica.sapio-cli e.g. /home/<usr>/.config/sapio-cli/config.json). The
config file can be overriden with the -c flag. This file allows users to set parameters
for compilation around:
- to use regtest/mainnet/signet/etc
- bitcoind to connect to & auth
- CTV emulator servers to use
- key-value mapping of nicknames to WASM plugin hashes.
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.