Skip to main content

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:

RolePartyDescription
BuyerAliceSends funds into the contract; wants the item
SellerBobWants to receive payment on delivery
ArbitersThree trusted partiesVerify delivery and cast an authoritative verdict

The workflow:

  1. Alice deploys the contract, locking the purchase price.
  2. She and Bob wait for delivery.
  3. If all goes well, Alice and all three arbiters sign confirmPayment → Bob gets paid.
  4. If delivery fails, Alice and all three arbiters sign refund → Alice gets her money back.
  5. 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:

MethodRequired signersWhenEffect
confirmPaymentBuyer + all 3 arbitersDelivery confirmedSeller receives funds
refundBuyer + all 3 arbitersDelivery failedBuyer receives refund
refundDeadlineBuyer onlyAfter deadlineBuyer 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:

PropTypeMeaning
buyerAddrRipemd160HASH160 of Alice's public key
sellerAddrRipemd160HASH160 of Bob's public key
arbiter0PubKeyFirst arbiter's 33-byte compressed public key
arbiter1PubKeySecond arbiter's 33-byte compressed public key
arbiter2PubKeyThird arbiter's 33-byte compressed public key
deadlineIntBlock 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

ConceptWhat it means in this contract
Stateless terminal contractcontract(name, props, {}, body) — four-argument form with empty state
PropsAll 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.locktimeSets the spending transaction's nLockTime in tests
txContext.inputSequenceMust be < 0xFFFFFFFF to enable nLockTime enforcement

What's Next

  • Script Context — understand ctx.nLockTime, ctx.nSequence, and ctx.value in depth, and how the Lambit compiler injects only the fields each method actually reads
  • Output Covenants — deeper walkthrough of p2pkhOutput, buildP2pkhOutput, assertOutputs, and branches(); the AtomicSwap walkthrough uses the same contract(name, props, {}, body) pattern as this tutorial