Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Escrow

Your starting point

The escrow program’s full source, a standard Anchor program with no tests, at examples/escrow/. Its built .so and IDL are committed too, so a fresh clone runs this chapter’s test without building anything:

git clone -b feat/buildable-ix https://github.com/cds-rs/anchor-litesvm
cd anchor-litesvm
cargo test -p anchor-litesvm --test book_escrow
examples/escrow/                                the program source (no tests)
crates/anchor-litesvm/tests/fixtures/escrow.so  the built program
crates/anchor-litesvm/idls/escrow.json          its IDL
crates/anchor-litesvm/tests/book_escrow.rs      this chapter's test

Changed the program? Rebuild the fixture with cd examples/escrow && anchor build.

The escrow program has three instructions. make creates an escrow PDA and deposits mint_a into its vault; take lets a counterparty pay mint_b and receive the vault’s mint_a; refund returns the deposit to the maker.

Every escrow carries a 90-day expiry, and take and refund sit on opposite sides of it: take stops working once the expiry passes, refund only starts working once it does. The Time-lock section below drives both sides of that boundary.

make and take also drive real SPL Token CPIs, transfers and init_if_needed associated-token-account creation, which makes this chapter a good place to read a multi-CPI tree once tokens are involved. This chapter drives all three instructions through anchor-litesvm.

Boot and make -> take

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
fn boot() -> anchor_litesvm::AnchorContext {
    AnchorLiteSVM::build_with_program(escrow::ID, "escrow", &common::fixture_bytes("escrow"))
}
}

One heads-up before the listing: bundles_from_idl! cannot derive every account for make and take. One field, escrow, needs computing by hand and passing in like any other bundle value; the code comment marks where, and the paragraph right after the listing explains why.

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
let mut ctx = boot();
let maker = ctx.cast_actor("Alice"); // Alice makes the escrow
let taker = ctx.cast_actor("Bob"); // Bob takes it
let mint_a = ctx.cast_mint("MintA", &maker, 6);
let mint_b = ctx.cast_mint("MintB", &maker, 6);

// Fund Alice with MintA (offered) and Bob with MintB (wanted).
let _alice_a = ctx.fund_ata(&maker, &mint_a, &maker, 1_000_000);
let _bob_b = ctx.fund_ata(&taker, &mint_b, &maker, 1_000_000);

// `escrow`'s PDA seeds an ix-arg (`seed`) that the IDL's own emitted
// seed-path names `seeds` (a vendored-source quirk), so the macro can't
// resolve it at build time and demotes it to a plain bundle field: the
// caller derives and supplies it directly, here and again in `take`.
let seed = 42u64;
let (escrow_pda, _bump) = Pubkey::find_program_address(
    &[b"escrow", maker.pubkey().as_ref(), &seed.to_le_bytes()],
    &escrow::ID,
);

ctx.tx(&[&maker])
    .build(
        MakeBundle {
            maker: maker.pubkey(),
            mint_a,
            mint_b,
            token_program: TOKEN_PROGRAM,
            escrow: escrow_pda,
        },
        escrow::client::args::Make {
            seed,
            receive: 1_000_000,
            deposit: 1_000_000,
        },
    )
    .send_ok();

// take: Bob pays MintB to Alice and receives MintA from the vault.
let result = ctx
    .tx(&[&taker])
    .build(
        TakeBundle {
            taker: taker.pubkey(),
            maker: maker.pubkey(),
            mint_a,
            mint_b,
            token_program: TOKEN_PROGRAM,
            escrow: escrow_pda,
        },
        escrow::client::args::Take {},
    )
    .send_ok();
}

bundles_from_idl! derives most of make and take’s accounts, the vault, the ATAs, the escrow PDA itself, where it can. escrow is the one exception, and the code comment above flags why: the instruction takes an argument named seed, but the IDL’s own emitted seed path names that same argument seeds, a vendored-source quirk in how the IDL was generated. The macro matches a seed path’s arguments back to the instruction’s by name, so a name that doesn’t line up can’t be resolved automatically, and escrow gets demoted from a derived field to a plain one. The caller computes escrow_pda with find_program_address instead, and passes it in like any other bundle field.

result.tree_string() renders the transaction as a CPI tree:


── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✓ 66972cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── Token [2] ✓ 105cu
    ├── Token [2] ✓ 105cu
    └── Token [2] ✓ 118cu
Compute Units (this run): 66972
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

The two AssociatedToken frames are init_if_needed creating the taker’s and maker’s associated token accounts: init_if_needed means the constraint creates the account only if it doesn’t already exist, and does nothing if it does. Nested inside each AssociatedToken frame, the Token/System frames are the ATA program’s own calls to size, fund, and initialize that account.

The three Token frames after that are take’s own CPIs, and they run in the order the instruction issues them: Bob pays Alice mint_b, the vault pays Bob mint_a, and the vault account closes, rent back to Alice.

Time-lock

litesvm_utils::TestHelpers::advance_days warps the SVM clock forward by a given number of days, which is how this section gets to the far side of the 90-day expiry without waiting for it in real time. Push past that expiry and take gets rejected before either ATA transfer happens:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
let ix = ctx.program().build_ix(
    TakeBundle {
        taker: taker.pubkey(),
        maker: maker.pubkey(),
        mint_a,
        mint_b,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Take {},
);

// The escrow expires 90 days after make. Jump 91 days forward.
ctx.svm.advance_days(91);

let result = ctx.send_err_named(ix, &[&taker], "EscrowExpired");
}

── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✗ 58772cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    └── Error: EscrowExpired
Error: InstructionError(0, Custom(6000))
Compute Units (this run): 58772
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Both AssociatedToken frames still run and succeed: init_if_needed only checks whether the account already exists, nothing about the escrow’s expiry, so account creation goes ahead regardless. The leaf is the program’s own expiry check, EscrowExpired, and it runs after both ATAs are already created, which is why the transaction fails only at that point rather than upfront.

Refund is the mirror image of the same expiry check: it only works after the 90 days pass, so calling it while still inside the window is rejected too:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
// No time warp: still inside the 90-day window, so refund must be rejected.
// `refund` doesn't sign with `maker` (it's a plain `SystemAccount`), but the
// transaction still needs a fee-payer signer, so `maker` signs in that role.
let ix = ctx.program().build_ix(
    RefundBundle {
        maker: maker.pubkey(),
        mint_a,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Refund {},
);
let result = ctx.send_err_named(ix, &[&maker], "EscrowNotExpired");
}

── escrow::Refund ──────────────────────────────────────────
Transaction  signers=[Alice]
└── escrow::Refund [1] ✗ 10360cu  signer=Alice
    └── Error: EscrowNotExpired
Error: InstructionError(0, Custom(6001))
Compute Units (this run): 10360
Fee: 5000 lamports
Legend (2):
  Alice  = 3xMuErAPF3QduutXSFqLfsKhMbzhoUWKHjfbHz8VgSTG
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Same shape as the expiry check above, mirrored: the program’s own EscrowNotExpired guard rejects the call because the 90 days haven’t elapsed yet, and this time there’s no ATA creation racing ahead of it, since refund doesn’t touch the associated-token-account machinery at all.

The escape hatch

build_ix_with builds every account honestly, then hands you a closure to override exactly one slot, the same escape hatch the vault chapter used against vault_state.

Mallory wants Bob’s take to pay out to her instead of the vault. The swapped account can’t be just anything, though: the vault field is an InterfaceAccount<'info, TokenAccount>, which checks that the account is owned by a token program and that its data actually unpacks as an initialized token account, before any #[account(...)] constraint on that field runs. So Mallory’s setup is to initialize her own, genuinely-owned mint_a associated token account first, a real token account that passes those checks cleanly, then submit take with the vault slot pointed at it:

#![allow(unused)]
fn main() {
// crates/anchor-litesvm/tests/book_escrow.rs
// Mallory owns a real, initialized mint_a token account (the
// confused-deputy setup: valid in every way except its authority is
// Mallory, not the escrow PDA). Zero balance is fine; it only needs to
// exist and deserialize. `maker` is the mint authority as elsewhere.
let mallory_vault = ctx.fund_ata(&mallory, &mint_a, &maker, 0);

// Point vault at Mallory's ATA instead of the escrow PDA's. The bundle
// derives every account honestly; the closure then swaps exactly the
// vault slot.
let ix = ctx.program().build_ix_with(
    TakeBundle {
        taker: taker.pubkey(),
        maker: maker.pubkey(),
        mint_a,
        mint_b,
        token_program: TOKEN_PROGRAM,
        escrow: escrow_pda,
    },
    escrow::client::args::Take {},
    |accounts| accounts.vault = mallory_vault,
);

let result = ctx.send_err_named(ix, &[&taker], "ConstraintTokenOwner");
}

── escrow::Take ────────────────────────────────────────────
Transaction  signers=[Bob]
└── escrow::Take [1] ✗ 59470cu  signer=Bob
    ├── AssociatedToken [2] ✓ 13416cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    ├── AssociatedToken [2] ✓ 15017cu
    │   ├── Token [3] ✓ 183cu
    │   ├── System [3] ✓ (no cu)
    │   ├── Token [3] ✓ 38cu
    │   └── Token [3] ✓ 235cu
    └── Error: ConstraintTokenOwner
Error: InstructionError(0, Custom(2015))
Compute Units (this run): 59470
Fee: 5000 lamports
Legend (2):
  Bob    = 9NxEkz3hopsvRkzgCfrLervpta7LAUWAYX2NeNYJyAfp
  escrow = 4iTshPQzLB9YstwVKJuHqd1UDMQpWRmE3NWeuNt7MrRt

Mallory’s ATA deserializes fine: real mint, real token account, right discriminator. Both AssociatedToken frames still succeed, same as the happy path, so nothing about account creation flags the substitution.

What catches it is vault’s own associated_token::authority = escrow constraint, checked once the account is already loaded: it reads the token account’s actual owner field and compares it to the escrow PDA. Mallory’s ATA is owned by Mallory, not by escrow, so the two don’t match and Anchor rejects with ConstraintTokenOwner.

Same confused-deputy lesson as the vault chapter’s ConstraintSeeds: a substituted account can be valid in every way that matters to the deserializer, and still belong to the wrong party. Here, as there, it’s one constraint, checked after the account is already loaded, that ties the field to the right owner and catches the swap.

The full test is crates/anchor-litesvm/tests/book_escrow.rs.