cxrlos
← Security

Smart-contract security

Adversarial testing of a Solana escrow

Protected Pay is an escrow program on Solana. It holds a payment until it's released to the seller, refunded, or claimed after a deadline. The program is the only thing between the escrowed money and anyone who wants to redirect it, so I test it the way someone would try to break it: wrong signer, swapped accounts, illegal state changes, timing tricks. And I check balances, not just whether a call succeeded. There are about 100 of these tests across the instructions, run in LiteSVM, plus a live run on devnet.

State is an enum, not a pile of booleans

A payment is always in exactly one state. I model that as an enum with the transitions written as methods, so an impossible combination like "released and refunded" can't even be represented. An illegal transition fails before any token moves. Released and Refunded are terminal.

This is also where one of the main guarantees lives. A disputed payment can only leave through resolve, withdraw, or concede, never through the expiry claim. So a deadline can't settle a dispute, and a sender can't claw back the escrow on their own.

/// Explicit lifecycle state. Modelled as an enum (not booleans) so that
/// impossible combinations such as "released and refunded" cannot be encoded.
pub enum PaymentState {
    Protected,  // escrowed; sender may release or dispute; anyone may claim after the deadline
    Disputed,   // locked pending the arbiter; the deadline no longer matters
    Released,   // terminal: principal went to the recipient
    Refunded,   // terminal: principal went back to the sender
}

impl PaymentState {
    /// Protected -> Disputed. Every other origin is rejected.
    pub fn dispute(self) -> Result<Self> {
        match self {
            PaymentState::Protected => Ok(PaymentState::Disputed),
            _ => err!(ProtectedPayError::InvalidStateTransition),
        }
    }
}
programs/protected-pay/src/state.rs

The arbiter can decide, but it can't redirect

The arbiter resolves a dispute by picking release or refund. What it shouldn't be able to do is send the money to itself, to a stranger, or cross-wire release to the wrong party. So the test takes a disputed payment and tries every one of those in a loop. Each one fails with InvalidSettlementDestination. Then it checks the vault balance didn't move and the payment is still Disputed. The arbiter gets to decide who wins the dispute. It doesn't get to touch the money.

for (resolution, dest) in [
    (Resolution::Release, arbiter_ata),
    (Resolution::Refund,  arbiter_ata),
    (Resolution::Release, stranger_ata),
    (Resolution::Refund,  stranger_ata),
    // Cross-wired: release to the sender's ATA, refund to the recipient's ATA.
    (Resolution::Release, p.sender_ata),
    (Resolution::Refund,  p.recipient_ata),
] {
    let mut ix = ResolveIx::new(&live, resolution);
    ix.destination_token_account = dest;
    let res = env.send(&[ix.ix()], &arbiter, &[&arbiter]);
    assert_error(&res, custom_code(ProtectedPayError::InvalidSettlementDestination));
}
assert_eq!(env.balance(&live.vault), ONE_USDC); // nothing moved
assert_eq!(env.payment(&live.payment).state, PaymentState::Disputed);
programs/protected-pay/tests/resolve_dispute.rs

Timing boundaries, on purpose

Opening a dispute needs the current time strictly before the deadline. Claiming an expired payment needs it at or after the deadline. I picked those two comparisons so no instant satisfies both. There's no moment where a payment could be disputed and claimed at once. The tests set the clock to exactly deadline-minus-one and exactly the deadline, for every protection period, and check the claim is rejected a second early and accepted on the dot.

// open_dispute: strictly before the deadline
require!(now < payment.protection_deadline, ProtectedPayError::ProtectionNotYetExpired);

// claim_expired: inclusive, at or after the deadline
require!(now >= payment.protection_deadline, ProtectedPayError::ProtectionNotExpired);
open_dispute.rs and claim_expired.rs: the two checks that can't both hold

What the tests cover

About 100 integration tests across the instructions: roughly 24 on creating a payment, 12 on resolving disputes, 12 on fee math, 10 on release, 9 each on withdraw and concede, 9 on opening a dispute, 8 on the expiry claim, 6 on vault isolation, and a golden-fixture test that fails if the program's interface drifts from what the SDK expects. They sort into wrong-signer, account substitution, illegal state transitions, timing boundaries, isolation between payments, and fee math at the edges of a u64.

The rule I stick to is that every settlement test checks token balances before and after, and checks the vault actually closes, instead of trusting that a successful transaction did the right thing. A test that only checks a call returned Ok would miss a program that moved the wrong amount to the right place.

Then I ran it live

LiteSVM is fast, but it's still a simulated runtime. So I also ran a driver against the deployed devnet program with fresh accounts, and checked each outcome by reading balances, state, and account existence back from the chain. That covered the normal paths (create, release, dispute to refund, dispute to release, permissionless expiry claim) and the rejections: unauthorized release and dispute, sending the money to the arbiter's own account or the counterparty's or the vault itself, a disputed payment blocking expiry, and double-settling a terminal payment. The expiry claim waited out the real one-hour window instead of fast-forwarding it.

One useful thing came out of that run: some attacks get stopped by Anchor's own account checks before my program's check even runs. Substituting a foreign token account fails with ConstraintTokenOwner. Passing the vault as the destination fails with ConstraintDuplicateMutableAccount. Same result, different error, which is worth knowing if you're building a client that has to tell users why something was rejected.

This is me testing my own program to show it rejects what it should. It's not an audit or a formal verification, and I'm not calling it one. It's on devnet, where the upgrade authority is still a single key. That's written up in the repo too.