A Solana Anchor program that lets one party (maker) lock SPL tokens of mint A against an asking price in mint B. A second party (taker) atomically exchanges the two, and the maker can refund the offer at any time. Offers may optionally carry a UTC expiry the chain enforces it on both make and take.
The React frontend is a single page with three sections:
- Make: create a new escrow. The PDA is auto-saved to localStorage so you can find it after refresh.
- Take by address: paste any escrow PDA, see its terms, take it.
- Your escrows: escrows the connected wallet has made; click the address to copy it for sharing with a taker. Refund any open offer.
flowchart LR
Maker([Maker])
Taker([Taker])
subgraph Browser
UI[React UI<br/>frontend/]
Wallet[Wallet Adapter]
end
RPC[(Solana RPC)]
subgraph Chain
Program[escrow program<br/>Exn6...M23wV]
EscrowPDA[(Escrow PDA<br/>per maker+seed)]
Vault[(Vault ATA<br/>mint A, escrow auth)]
end
Maker --> UI
Taker --> UI
UI --> Wallet
UI --> RPC
Wallet --> RPC
RPC --> Program
Program -->|read / write| EscrowPDA
Program -->|transfer SPL| Vault
| Instruction | Args | Effect |
|---|---|---|
make |
seed: u64, amount: u64, deposit: u64, expiry_utc: Option<i64> |
Creates the escrow PDA + vault ATA, transfers deposit of mint A from maker into the vault, records amount of mint B as the price. Rejects an expiry already in the past. |
take |
Transfers amount of mint B from taker to maker, drains the vault (mint A) to taker, closes both vault + escrow. Rejects after expiry. |
|
refund |
Returns the vault contents to the maker and closes both accounts. No expiry check the maker can always reclaim funds. |
deposit and amount are decoupled: deposit is how much of mint A the maker locks; amount is what the taker must pay in mint B. They aren't required to match, which gives the maker price discretion.
#[account]
pub struct Escrow {
pub seed: u64,
pub maker: Pubkey,
pub mint_a: Pubkey,
pub mint_b: Pubkey,
pub amount: u64,
pub bump: u8,
pub expiry_utc: Option<i64>,
}escrow=["escrow", maker, seed_le_bytes](program-owned account)vault= associated token account ofmint_afor ownerescrow
flowchart LR
Maker[maker pubkey]
Seed[u64 seed]
Maker -- "escrow, maker, seed" --> Escrow[escrow PDA<br/>holds state]
Escrow -- ATA derivation --> Vault[vault ATA<br/>holds mint A]
stateDiagram-v2
[*] --> Open: make
Open --> Open: (no-op while alive)
Open --> Settled: take
Open --> Refunded: refund
Open --> Expired: now ≥ expiry_utc
Expired --> Refunded: refund
Settled --> [*]
Refunded --> [*]
A taker can only act on Open escrows. After expiry the only legal transition is refund.
sequenceDiagram
autonumber
actor Taker
participant UI as React UI
participant P as escrow program
participant V as Vault ATA
participant Mk as Maker ATA B
participant Tk as Taker ATA A
Taker->>UI: clicks Take on a listing
UI->>P: methods.take().accountsStrict({...}).rpc()
P->>P: assert now ≤ expiry_utc
P->>Mk: transfer_checked(amount, mint_b, taker → maker)
P->>Tk: transfer_checked(deposit, mint_a, vault → taker)
P->>V: close_account(vault → maker rent)
P->>P: close escrow → maker rent
All tests are Rust LiteSVM (programs/escrow/tests/). No JS/TS suite — clock-warp + deterministic mints are easier in-Rust.
anchor test-
Start a local validator (pick one):
solana-test-validator # or surfpool -
Build + deploy the program:
anchor build anchor deploy
-
Mint two test tokens to your wallet so you have something to escrow:
spl-token create-token spl-token create-account <mintA> spl-token create-account <mintB> spl-token mint <mintA> 1000 spl-token mint <mintB> 1000
-
Point the frontend at the validator and run the dev server:
echo 'VITE_RPC_URL=http://127.0.0.1:8899' > .env.local yarn dev
-
Open the printed URL, connect a wallet set to Localnet, paste the two mint addresses into the Make form, click "Create escrow". Copy the resulting escrow PDA (click it on the card) and paste it into the Take by address form from a second wallet to fill the offer.
yarn build
yarn typecheck
yarn lint
yarn format
yarn format:check