Rúnar

Multi-Party Escrow

This tutorial shows you how to build a multi-party escrow contract where funds are locked until the involved parties agree on the outcome. It demonstrates multi-signature spending conditions, role-based authorization, and conditional release and refund flows — all enforced on-chain by the contract’s spending conditions.

By the end of this tutorial, you will understand:

  • How to encode multiple roles (buyer, seller, arbiter) in a SmartContract
  • Multi-signature patterns using checkSig
  • How different public methods create different spending paths
  • The trade-offs between stateless and stateful escrow designs
  • Testing contracts with multiple key pairs and both success and failure scenarios

Prerequisites

Understanding Escrow on BSV

In a traditional escrow, a trusted third party holds funds until both the buyer and seller fulfill their obligations. On BSV, the escrow is the contract itself — funds are locked in a UTXO, and the spending conditions define who can release or refund them under what circumstances.

The contract has three participants:

RoleDescription
BuyerDeposits funds into the escrow
SellerReceives funds if the transaction completes
ArbiterResolves disputes; can authorize refunds

The contract provides two spending paths:

MethodRequired SignaturesOutcome
releaseSeller + BuyerFunds go to the seller (both parties agree)
refundBuyer + ArbiterFunds returned to the buyer (dispute resolved in buyer’s favor)

This design ensures:

  • The seller cannot take the funds without the buyer’s agreement.
  • The buyer cannot take the funds back without the arbiter’s agreement.
  • The arbiter alone cannot move the funds — they can only authorize refunds alongside the buyer.

Writing the Contract

Create src/contracts/Escrow.runar.ts:

import {
  SmartContract,
  assert,
  PubKey,
  Sig,
  checkSig,
} from 'runar-lang';

class Escrow extends SmartContract {
  readonly buyer: PubKey;
  readonly seller: PubKey;
  readonly arbiter: PubKey;

  constructor(buyer: PubKey, seller: PubKey, arbiter: PubKey) {
    super(buyer, seller, arbiter);
    this.buyer = buyer;
    this.seller = seller;
    this.arbiter = arbiter;
  }

  public release(sellerSig: Sig, buyerSig: Sig) {
    assert(checkSig(sellerSig, this.seller));
    assert(checkSig(buyerSig, this.buyer));
  }

  public refund(buyerSig: Sig, arbiterSig: Sig) {
    assert(checkSig(buyerSig, this.buyer));
    assert(checkSig(arbiterSig, this.arbiter));
  }
}

export { Escrow };

Walkthrough

Why Stateless?

This escrow uses SmartContract (stateless) rather than StatefulSmartContract. The contract does not need to evolve over time — it has two possible outcomes (release or refund), and either outcome fully spends the UTXO. There is no intermediate state to track.

This is a common pattern: if your contract’s lifecycle is “lock funds, then spend under one of N conditions,” a stateless contract is the right choice. Simpler script, lower fees, no covenant overhead.

The Three Roles

readonly buyer: PubKey;
readonly seller: PubKey;
readonly arbiter: PubKey;

All three roles are readonly and set at deployment time. Each is a compressed public key (33 bytes). The roles are embedded directly in the locking script, so anyone can inspect the transaction to see who the participants are.

The release Method

public release(sellerSig: Sig, buyerSig: Sig) {
  assert(checkSig(sellerSig, this.seller));
  assert(checkSig(buyerSig, this.buyer));
}

Release requires both the seller’s and buyer’s signatures. This represents the happy path — the buyer received the goods or services and agrees to release payment. Both parties must cooperate.

The seller’s signature is checked first because the seller is the one constructing the release transaction. The buyer provides their signature as confirmation.

The refund Method

public refund(buyerSig: Sig, arbiterSig: Sig) {
  assert(checkSig(buyerSig, this.buyer));
  assert(checkSig(arbiterSig, this.arbiter));
}

Refund requires the buyer’s and arbiter’s signatures. This is the dispute path — the buyer claims the seller did not fulfill their obligations, and the arbiter agrees. The arbiter’s role is to be an impartial judge.

Notice the seller is not required for a refund. If the buyer and arbiter agree, the seller cannot block the refund. Similarly, the arbiter cannot unilaterally move funds — they need the buyer’s cooperation.

What About Seller + Arbiter?

The current contract intentionally does not include a sellerRelease(sellerSig, arbiterSig) method. If the seller and arbiter could release funds together without the buyer, it would create a collusion risk. The design forces the buyer to participate in any release of funds, which protects the buyer’s interests.

If your use case requires seller + arbiter release (for example, a marketplace where the buyer has disappeared), you can add a third public method:

public arbitratedRelease(sellerSig: Sig, arbiterSig: Sig) {
  assert(checkSig(sellerSig, this.seller));
  assert(checkSig(arbiterSig, this.arbiter));
}

This is a design decision, not a technical limitation.

Compiling

runar compile contracts/Escrow.runar.ts --output ./artifacts --asm

The compiled script will contain the three public keys and the OP_CHECKSIG operations. Because this is a stateless contract with only signature checks, the script is compact and efficient.

Testing the Escrow

Create tests/Escrow.test.ts:

import { describe, it, expect } from 'vitest';
import { TestContract, ALICE, BOB, CHARLIE } from 'runar-testing';
import { readFileSync } from 'fs';

describe('Escrow', () => {
  const source = readFileSync('./src/contracts/Escrow.runar.ts', 'utf-8');

  // ALICE = buyer, BOB = seller, CHARLIE = arbiter
  function createEscrow() {
    return TestContract.fromSource(source, {
      buyer: ALICE.pubKey,
      seller: BOB.pubKey,
      arbiter: CHARLIE.pubKey,
    });
  }

  describe('release', () => {
    it('should succeed with seller + buyer signatures', () => {
      const contract = createEscrow();

      const result = contract.call('release', {
        sellerSig: BOB.privKey,
        buyerSig: ALICE.privKey,
      });

      expect(result.success).toBe(true);
    });

    it('should fail with wrong seller key', () => {
      const contract = createEscrow();

      const result = contract.call('release', {
        sellerSig: CHARLIE.privKey,
        buyerSig: ALICE.privKey,
      });

      expect(result.success).toBe(false);
    });

    it('should fail with wrong buyer key', () => {
      const contract = createEscrow();

      const result = contract.call('release', {
        sellerSig: BOB.privKey,
        buyerSig: CHARLIE.privKey,
      });

      expect(result.success).toBe(false);
    });

    it('should fail with only seller signature', () => {
      const contract = createEscrow();

      // Seller tries to release alone (using seller key for both)
      const result = contract.call('release', {
        sellerSig: BOB.privKey,
        buyerSig: BOB.privKey,
      });

      expect(result.success).toBe(false);
    });
  });

  describe('refund', () => {
    it('should succeed with buyer + arbiter signatures', () => {
      const contract = createEscrow();

      const result = contract.call('refund', {
        buyerSig: ALICE.privKey,
        arbiterSig: CHARLIE.privKey,
      });

      expect(result.success).toBe(true);
    });

    it('should fail without arbiter agreement', () => {
      const contract = createEscrow();

      // Buyer tries to refund alone
      const result = contract.call('refund', {
        buyerSig: ALICE.privKey,
        arbiterSig: ALICE.privKey,
      });

      expect(result.success).toBe(false);
    });

    it('should fail if seller tries to block refund', () => {
      const contract = createEscrow();

      // Seller cannot participate in the refund flow
      const result = contract.call('refund', {
        buyerSig: ALICE.privKey,
        arbiterSig: BOB.privKey,
      });

      expect(result.success).toBe(false);
    });
  });
});

Run the tests:

runar test

Expected output:

 ✓ tests/Escrow.test.ts (7 tests) 89ms
   ✓ Escrow > release > should succeed with seller + buyer signatures
   ✓ Escrow > release > should fail with wrong seller key
   ✓ Escrow > release > should fail with wrong buyer key
   ✓ Escrow > release > should fail with only seller signature
   ✓ Escrow > refund > should succeed with buyer + arbiter signatures
   ✓ Escrow > refund > should fail without arbiter agreement
   ✓ Escrow > refund > should fail if seller tries to block refund

 Test Files  1 passed (1)
      Tests  7 passed (7)
   Duration  421ms

Deploying the Escrow

The escrow deployment is typically initiated by the buyer, who locks their payment into the contract:

runar deploy ./artifacts/Escrow.json \
  --network testnet \
  --key <buyer-WIF-key> \
  --satoshis 100000

The --satoshis amount is the escrowed payment. After deployment, the buyer shares the TxID with the seller and arbiter. All three participants can inspect the on-chain script to verify the contract terms.

Releasing Funds

When both parties are satisfied, the seller constructs a release transaction and the buyer co-signs:

runar call <txid>:0 release \
  --artifact ./artifacts/Escrow.json \
  --network testnet \
  --signers seller:<seller-WIF>,buyer:<buyer-WIF>

Initiating a Refund

If there is a dispute and the arbiter rules in the buyer’s favor:

runar call <txid>:0 refund \
  --artifact ./artifacts/Escrow.json \
  --network testnet \
  --signers buyer:<buyer-WIF>,arbiter:<arbiter-WIF>

Design Variations

Adding a Timeout

The current contract has no expiry. If one party disappears, the funds are locked forever. You can add a time-locked refund by using StatefulSmartContract with extractLocktime:

public timeoutRefund(buyerSig: Sig, txPreimage: SigHashPreimage) {
  assert(checkPreimage(txPreimage));
  assert(extractLocktime(txPreimage) > this.deadline);
  assert(checkSig(buyerSig, this.buyer));
}

This allows the buyer to reclaim funds after the deadline without needing the arbiter.

M-of-N Variations

The 2-of-3 pattern used here is the most common escrow design, but you can create other configurations:

  • 2-of-2 (buyer + seller only, no arbiter): Simpler but no dispute resolution.
  • 3-of-5 (committee escrow): For high-value transactions requiring broader consensus.
  • 1-of-3 (any party can release): Not recommended for escrow but useful for shared wallets.

Key Concepts Recap

ConceptWhat You Learned
Multi-signature patternsUsing checkSig with multiple public keys for role-based access
Multiple public methodsDifferent spending paths (release vs. refund) in one contract
Stateless designWhy escrow does not need StatefulSmartContract
Role-based authorizationBuyer, seller, arbiter with different permissions
Security considerationsWhy certain role combinations are included or excluded

What’s Next