Escrow Tutorial
Overview
In this tutorial we will build an on-chain multi-signature escrow contract, test it locally, and deploy it to the OP_CAT testnet. The contract handles three distinct lifecycle paths, each enforced entirely by Bitcoin Script.
A traditional escrow involves a trusted third party who holds funds until transaction conditions are met. Our implementation replaces that third party with a Bitcoin covenant: the smart contract itself enforces who gets paid, when, and under what conditions — with no external authority required.
Before starting, ensure all prerequisite tools are installed
What Is a Multi-Signature Escrow?
In our implementation, three roles participate in a purchase agreement:
| Role | Party | Description |
|---|---|---|
| Buyer | Alice | Sends funds into the contract; wants the item |
| Seller | Bob | Wants to receive payment on delivery |
| Arbiters | Three trusted parties | Verify delivery and cast an authoritative verdict |
The workflow:
- Alice deploys the contract, locking the purchase price.
- She and Bob wait for delivery.
- If all goes well, Alice and all three arbiters sign
confirmPayment→ Bob gets paid. - If delivery fails, Alice and all three arbiters sign
refund→ Alice gets her money back. - If the arbiters become unresponsive and the deadline passes, Alice alone can
call
refundDeadline→ Alice gets her money back, no arbiter needed.
There are three public methods:
| Method | Required signers | When | Effect |
|---|---|---|---|
confirmPayment | Buyer + all 3 arbiters | Delivery confirmed | Seller receives funds |
refund | Buyer + all 3 arbiters | Delivery failed | Buyer receives refund |
refundDeadline | Buyer only | After deadline | Buyer receives refund |
Contract Design
Why This Is Stateless
Unlike the Voting or Auction contracts, this escrow has no evolving state. It is funded once, then spent once along exactly one of the three paths. There is nothing to tally, nothing to increment, and no successor UTXO is needed.
In Lambit, a "stateless" terminal contract uses the four-argument
contract(name, props, {}, body) form with an empty state schema {}. This
is identical to the AtomicSwap pattern from Output Covenants.
Every method returns { outputs, check } — a terminal payout that commits the
full contract value to a P2PKH output and then ends the UTXO's life.
Props
All values are fixed at deploy time and baked into the locking script bytecode:
| Prop | Type | Meaning |
|---|---|---|
buyerAddr | Ripemd160 | HASH160 of Alice's public key |
sellerAddr | Ripemd160 | HASH160 of Bob's public key |
arbiter0 | PubKey | First arbiter's 33-byte compressed public key |
arbiter1 | PubKey | Second arbiter's 33-byte compressed public key |
arbiter2 | PubKey | Third arbiter's 33-byte compressed public key |
deadline | Int | Block height (< 500 000 000) or Unix timestamp |
Storing arbiter public keys (not address hashes) is intentional: the
checkMultiSig opcode verifies signatures directly against public keys, so
pre-hashing would require an extra derivation step in Script.
Storing buyer and seller as address hashes (Ripemd160) is intentional:
the contract never needs to sign on their behalf — it only needs to route value
to their P2PKH outputs. Storing only the hash is more compact and avoids
exposing public keys before the UTXO is spent.
Write the Contract
Create contracts/multisig-escrow.ts:
import {
TypeTag,
and,
checkLocktime,
checkMultiSig,
checkSig,
contract,
eq,
method,
p2pkhOutput,
pubKey2Addr,
} from '@opcat-labs/lambit';
// Number of arbiters required. Both confirmPayment and refund require
// ALL three arbiters — this is a 3-of-3 multisig on the arbiter side.
export const N_ARBITERS = 3 as const;
// MultiSigEscrow is a stateless terminal contract: it is deployed once and
// spent exactly once along one of three paths. There is no evolving state,
// so the state schema is empty ({}).
//
// Using contract(name, props, {}, body) — the four-argument stateful form
export const MultiSigEscrow = contract(
'MultiSigEscrow',
// Props: baked into the locking script at deploy time
{
// HASH160 of Alice's public key. The contract routes refunds here.
buyerAddr: TypeTag.Ripemd160,
// HASH160 of Bob's public key. The contract routes payment here.
sellerAddr: TypeTag.Ripemd160,
// All three arbiter public keys (33 bytes each, compressed form).
// checkMultiSig verifies signatures directly against these keys.
arbiter0: TypeTag.PubKey,
arbiter1: TypeTag.PubKey,
arbiter2: TypeTag.PubKey,
// Values < 500_000_000 → block height; values >= 500_000_000 → timestamp.
// checkLocktime (OP_CHECKLOCKTIMEVERIFY) also enforces that nLockTime
deadline: TypeTag.Int,
},
({ props }) => ({
// confirmPayment
//
// Called when the item is delivered in the correct condition.
// Requires: Alice's signature + signatures from ALL THREE arbiters.
// Effect: the entire contract value flows to Bob's P2PKH address.
confirmPayment: method(
{
buyerSig: TypeTag.Sig, // Alice's Schnorr signature
buyerPubKey: TypeTag.PubKey, // Alice's 33-byte public key
arbiterSig0: TypeTag.Sig, // First arbiter's signature
arbiterSig1: TypeTag.Sig, // Second arbiter's signature
arbiterSig2: TypeTag.Sig, // Third arbiter's signature
},
// The trailing `ctx` parameter is the TxContext.
// The compiler injects only the fields this method actually reads
// here, just `ctx.value` (the satoshi value of the UTXO being spent).
(buyerSig, buyerPubKey, arbiterSig0, arbiterSig1, arbiterSig2, ctx) => ({
// Terminal payout: seller receives the full contract value.
// p2pkhOutput(satoshis, pkh).
outputs: [p2pkhOutput(ctx.value, props.sellerAddr)],
// Spending predicate: ALL of the following must hold.
check: and(
// 1. The provided public key belongs to Alice (address consistency).
// pubKey2Addr(pk) = OP_HASH160 pk — same as hash160(pk).
eq(pubKey2Addr(buyerPubKey), props.buyerAddr),
and(
// 2. Alice's signature is valid for this transaction.
checkSig(buyerSig, buyerPubKey),
// 3. All three arbiters' signatures are valid (3-of-3 multisig).
// checkMultiSig(sigs[], pubkeys[]) emits OP_CHECKMULTISIG.
// Bitcoin's OP_CHECKMULTISIG requires sigs in the same order
// as their corresponding pubkeys in the pubkey list.
checkMultiSig(
[arbiterSig0, arbiterSig1, arbiterSig2],
[props.arbiter0, props.arbiter1, props.arbiter2],
),
),
),
}),
),
// refund
//
// Called when the item was not delivered or was faulty.
// Requires: Alice's signature + signatures from ALL THREE arbiters.
// Effect: the entire contract value flows back to Alice's P2PKH address.
//
// The predicate is identical to confirmPayment except for the output
refund: method(
{
buyerSig: TypeTag.Sig,
buyerPubKey: TypeTag.PubKey,
arbiterSig0: TypeTag.Sig,
arbiterSig1: TypeTag.Sig,
arbiterSig2: TypeTag.Sig,
},
(buyerSig, buyerPubKey, arbiterSig0, arbiterSig1, arbiterSig2, ctx) => ({
// Terminal payout: buyer receives the full contract value (refund).
outputs: [p2pkhOutput(ctx.value, props.buyerAddr)],
check: and(
eq(pubKey2Addr(buyerPubKey), props.buyerAddr),
and(
checkSig(buyerSig, buyerPubKey),
checkMultiSig(
[arbiterSig0, arbiterSig1, arbiterSig2],
[props.arbiter0, props.arbiter1, props.arbiter2],
),
),
),
}),
),
// refundDeadline
//
// Called after the deadline if the arbiters are unresponsive.
// Requires: Alice's signature only - no arbiter agreement needed.
// Effect: the entire contract value flows back to Alice's P2PKH address.
refundDeadline: method(
{
buyerSig: TypeTag.Sig,
buyerPubKey: TypeTag.PubKey,
},
(buyerSig, buyerPubKey, ctx) => ({
// Terminal payout: buyer receives the full contract value.
outputs: [p2pkhOutput(ctx.value, props.buyerAddr)],
check: and(
eq(pubKey2Addr(buyerPubKey), props.buyerAddr),
and(
checkLocktime(props.deadline),
checkSig(buyerSig, buyerPubKey),
),
),
}),
),
}),
);
Test the Contract
Replace test/multisig-escrow.test.ts with:
import { expect } from 'chai';
import {
buildArtifact,
createMemoryProvider,
createSigner,
} from '@opcat-labs/lambit';
import { MultiSigEscrow, N_ARBITERS } from '../contracts/multisig-escrow';
// Purchase price locked in the contract UTXO.
// All three spending paths return exactly this amount to buyer or seller.
const PURCHASE_PRICE = 10_000n;
// Deadline expressed as a block height.
// Values < 500_000_000 → block height; values >= 500_000_000 → Unix timestamp.
const DEADLINE = 850_000n;
// nSequence value that enables nLockTime enforcement.
// Must be < 0xFFFFFFFF (final) for OP_CHECKLOCKTIMEVERIFY to activate.
const NON_FINAL_SEQUENCE = 0xffff_fffe;
describe('MultiSigEscrow — artifact', () => {
it('has the correct contract name', () => {
const artifact = buildArtifact(MultiSigEscrow);
expect(artifact.contract).to.equal('MultiSigEscrow');
});
it('exposes exactly three public methods, all terminal', () => {
const artifact = buildArtifact(MultiSigEscrow);
const fns = artifact.abi.filter((e) => e.type === 'function');
expect(fns).to.have.length(3);
expect(fns.map((f) => f.name)).to.deep.equal([
'confirmPayment',
'refund',
'refundDeadline',
]);
fns.forEach((f) => expect(f.returnShape).to.equal('terminal'));
});
it('correctly counts arbiter params in confirmPayment and refund', () => {
const artifact = buildArtifact(MultiSigEscrow);
const confirmAbi = artifact.abi.find(
(e) => e.type === 'function' && e.name === 'confirmPayment',
);
const arbiterSigParams = confirmAbi!.params.filter((p) =>
p.name.startsWith('arbiterSig'),
);
expect(arbiterSigParams).to.have.length(N_ARBITERS);
});
});
describe('MultiSigEscrow - verification', () => {
let buyerSigner
let arbiter0Signer
let arbiter1Signer
let arbiter2Signer
let sellerSigner
let buyerPubKey
let buyerAddr
let sellerAddr
let arbiter
let arbiter1
let arbiter2
let escrow: ReturnType<typeof MultiSigEscrow>;
before(async () => {
buyerSigner = createSigner();
arbiter0Signer = createSigner();
arbiter1Signer = createSigner();
arbiter2Signer = createSigner();
sellerSigner = createSigner();
buyerPubKey = await buyerSigner.getPublicKey();
buyerAddr = await buyerSigner.getPublicKeyHash();
sellerAddr = await sellerSigner.getPublicKeyHash();
arbiter0 = await arbiter0Signer.getPublicKey();
arbiter1 = await arbiter1Signer.getPublicKey();
arbiter2 = await arbiter2Signer.getPublicKey();
escrow = MultiSigEscrow({
buyerAddr,
sellerAddr,
arbiter0,
arbiter1,
arbiter2,
deadline: DEADLINE,
});
});
it('verifies confirmPayment with buyer + all 3 arbiters', async () => {
await escrow.methods.confirmPayment.verify(
{ buyerPubKey, arbiter0, arbiter1, arbiter2 },
{
context: { satoshis: PURCHASE_PRICE },
// Primary signer is the buyer; arbiter signers are passed per-key.
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
arbiterSig0: psbt.getSig(0, { publicKey: arbiter0, signer: arbiter0Signer }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
);
});
it('rejects confirmPayment when an arbiter signature is missing', async () => {
// Pass a wrong arbiter public key (seller's key) → checkMultiSig fails
const wrongArbiter = await sellerSigner.getPublicKey();
await expect(
escrow.methods.confirmPayment.verify(
{},
{ buyerPubKey, arbiter0: wrongArbiter, arbiter1, arbiter2 },
{
context: { satoshis: PURCHASE_PRICE },
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
// Intentionally using sellerSigner for arbiter0 — mismatch
arbiterSig0: psbt.getSig(0, { publicKey: wrongArbiter, signer: sellerSigner }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
it('rejects confirmPayment when the buyer key does not match buyerAddr', async () => {
// Pass the seller's key as the buyer — eq(pubKey2Addr(pk), buyerAddr) fails
const sellerPubKey = await sellerSigner.getPublicKey();
await expect(
escrow.methods.confirmPayment.verify(
{},
{ buyerPubKey: sellerPubKey, arbiter0, arbiter1, arbiter2 },
{
context: { satoshis: PURCHASE_PRICE },
signer: sellerSigner, // wrong signer provided as buyer
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: sellerPubKey }),
buyerPubKey: sellerPubKey,
arbiterSig0: psbt.getSig(0, { publicKey: arbiter0, signer: arbiter0Signer }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
// refund
it('verifies refund with buyer + all 3 arbiters', async () => {
await escrow.methods.refund.verify(
{},
{ buyerPubKey, arbiter0, arbiter1, arbiter2 },
{
context: { satoshis: PURCHASE_PRICE },
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
arbiterSig0: psbt.getSig(0, { publicKey: arbiter0, signer: arbiter0Signer }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
);
});
it('rejects refund when one arbiter signature is wrong', async () => {
await expect(
escrow.methods.refund.verify(
{},
{ buyerPubKey, arbiter0, arbiter1, arbiter2 },
{
context: { satoshis: PURCHASE_PRICE },
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
// arbiterSig0 signed by the wrong key (buyer acts as arbiter0)
arbiterSig0: psbt.getSig(0, { publicKey: buyerPubKey }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
// refundDeadline
it('verifies refundDeadline after the deadline when buyer signs', async () => {
await escrow.methods.refundDeadline.verify(
{},
{ buyerPubKey },
{
context: {
satoshis: PURCHASE_PRICE,
// txContext tells the BVM what nLockTime and nSequence to use.
// locktime must be >= props.deadline for OP_CLTV to pass.
// inputSequence must be < 0xFFFFFFFF to enable nLockTime enforcement.
},
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
}),
txContext: {
inputSequence: NON_FINAL_SEQUENCE,
locktime: Number(DEADLINE), // exactly at deadline — passes
},
},
);
});
it('rejects refundDeadline before the deadline', async () => {
await expect(
escrow.methods.refundDeadline.verify(
{},
{ buyerPubKey },
{
context: { satoshis: PURCHASE_PRICE },
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
}),
txContext: {
inputSequence: NON_FINAL_SEQUENCE,
locktime: Number(DEADLINE) - 1, // one block before deadline — fails
},
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
it('rejects refundDeadline when the buyer is an impostor', async () => {
// A non-buyer tries to call refundDeadline after the deadline.
// eq(pubKey2Addr(impostorPubKey), props.buyerAddr) fails.
const impostorSigner = createSigner();
const impostorPubKey = await impostorSigner.getPublicKey();
await expect(
escrow.methods.refundDeadline.verify(
{},
{ buyerPubKey: impostorPubKey },
{
context: { satoshis: PURCHASE_PRICE },
signer: impostorSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: impostorPubKey }),
buyerPubKey: impostorPubKey,
}),
txContext: {
inputSequence: NON_FINAL_SEQUENCE,
locktime: Number(DEADLINE),
},
},
),
).to.be.rejectedWith(/BVM verification failed/);
});
});
Run the tests:
```bash
npx mocha --no-config --require tsx test/multisig-escrow.test.ts
All tests should pass without touching the network.
Deploy and Call on the Testnet
Create scripts/deploy.ts:
// Run:
// TESTNET_WIF=<buyer_wif> npx tsx scripts/deploy.ts
import {
createTestnetProvider,
createWifSigner,
createSigner,
getWifAddress,
} from '@opcat-labs/lambit';
import { MultiSigEscrow } from '../contracts/multisig-escrow';
// Guard
const TESTNET_WIF = process.env.TESTNET_WIF;
if (!TESTNET_WIF) {
throw new Error('Set TESTNET_WIF before running this script.');
}
// Escrow parameters
// Purchase price in satoshis. Locked by the buyer at deploy time.
const PURCHASE_PRICE = 3_000n;
// Deadline: a block height that has already passed on the testnet, so we
// can demonstrate the refundDeadline path immediately.
// Set this to a height BEFORE the current testnet tip for testing.
const DEADLINE = 800_000n;
const NON_FINAL_SEQUENCE = 0xffff_fffe;
// Party setup
// Buyer (Alice) - uses the funded WIF from the environment.
const buyerSigner = createWifSigner(TESTNET_WIF, 'testnet');
const buyerPubKey = await buyerSigner.getPublicKey();
const buyerAddr = await buyerSigner.getPublicKeyHash();
const sellerSigner = createSigner();
const sellerAddr = await sellerSigner.getPublicKeyHash();
const arbiter0Signer = createSigner();
const arbiter1Signer = createSigner();
const arbiter2Signer = createSigner();
const arbiter0 = await arbiter0Signer.getPublicKey();
const arbiter1 = await arbiter1Signer.getPublicKey();
const arbiter2 = await arbiter2Signer.getPublicKey();
// createTestnetProvider discovers funded UTXOs from the buyer's WIF address.
const provider = createTestnetProvider({ wif: TESTNET_WIF, network: 'testnet' });
console.log('Buyer (Alice) address:', getWifAddress(TESTNET_WIF, 'testnet'));
console.log('Deadline block height:', DEADLINE.toString());
// Bind the contract
const escrow = MultiSigEscrow({
buyerAddr,
sellerAddr,
arbiter0,
arbiter1,
arbiter2,
deadline: DEADLINE,
});
console.log('\nLocking script:', escrow.artifact.hex);
// Deploy
console.log('\n=== Deploying MultiSigEscrow ===');
const deployed = await escrow.deploy({}, {
provider,
satoshis: PURCHASE_PRICE,
});
console.log(`MultiSigEscrow deployed: ${deployed.utxo.txid}:${deployed.utxo.vout}`);
console.log(`Purchase price locked: ${deployed.utxo.satoshis} satoshis`);
console.log('\n=== Demonstrating Path A: confirmPayment ===');
// Deploy a fresh UTXO.
const deployedA = await escrow.deploy({}, { provider, satoshis: PURCHASE_PRICE });
const confirmResult = await deployedA.methods.confirmPayment.call(
{ buyerPubKey, arbiter0, arbiter1, arbiter2 },
{
provider,
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
arbiterSig0: psbt.getSig(0, { publicKey: arbiter0, signer: arbiter0Signer }),
arbiterSig1: psbt.getSig(0, { publicKey: arbiter1, signer: arbiter1Signer }),
arbiterSig2: psbt.getSig(0, { publicKey: arbiter2, signer: arbiter2Signer }),
}),
},
);
console.log(`confirmPayment called: ${confirmResult.txid}`);
console.log('Seller (Bob) receives the purchase price.');
console.log('\n=== Demonstrating Path C: refundDeadline ===');
// The escrow UTXO from the initial deploy.
const refundResult = await deployed.methods.refundDeadline.call(
{ buyerPubKey },
{
provider,
signer: buyerSigner,
invoke: (psbt) => ({
buyerSig: psbt.getSig(0, { publicKey: buyerPubKey }),
buyerPubKey,
}),
txContext: {
inputSequence: NON_FINAL_SEQUENCE,
locktime: Number(DEADLINE),
},
},
);
console.log(`refundDeadline called: ${refundResult.txid}`);
console.log('Buyer (Alice) receives the purchase price back.');
console.log('\n=== Deployment complete ===');
console.log(`deployTx : https://explorer.opcat-testnet.io/tx/${deployed.utxo.txid}`);
console.log(`confirmTx : https://explorer.opcat-testnet.io/tx/${confirmResult.txid}`);
console.log(`refundTx : https://explorer.opcat-testnet.io/tx/${refundResult.txid}`);
Run it:
TESTNET_WIF=<your_wif> npx tsx scripts/deploy.ts
Key Concepts Summary
| Concept | What it means in this contract |
|---|---|
| Stateless terminal contract | contract(name, props, {}, body) — four-argument form with empty state |
| Props | All six fields fixed at deploy time: addresses, arbiter keys, deadline |
{ outputs, check } | Terminal payout shape — UTXO consumed, no successor state |
p2pkhOutput(value, pkh) | Commits the spent value to a P2PKH output at the given address |
pubKey2Addr(pk) | OP_HASH160 pk — verifies the caller owns the key for a stored address |
checkMultiSig(sigs, pks) | OP_CHECKMULTISIG — all N-of-N signatures must be valid |
checkLocktime(deadline) | OP_CHECKLOCKTIMEVERIFY — enforces nLockTime >= deadline |
txContext.locktime | Sets the spending transaction's nLockTime in tests |
txContext.inputSequence | Must be < 0xFFFFFFFF to enable nLockTime enforcement |
What's Next
- Script Context — understand
ctx.nLockTime,ctx.nSequence, andctx.valuein depth, and how the Lambit compiler injects only the fields each method actually reads - Output Covenants — deeper walkthrough of
p2pkhOutput,buildP2pkhOutput,assertOutputs, andbranches(); the AtomicSwap walkthrough uses the samecontract(name, props, {}, body)pattern as this tutorial