A token escrow lets two parties exchange different SPL tokens trustlessly. The maker deposits token A into a PDA-controlled vault and records how much token B they want in return. Any taker can then satisfy the offer by sending the required amount of token B to the maker and receiving the escrowed token A. If no taker appears, the maker can call refund to reclaim their tokens. This walkthrough covers the complete Quasar implementation: theDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/blueshift-gg/quasar/llms.txt
Use this file to discover all available pages before exploring further.
Escrow state account, the Make, Take, and Refund instructions, and the on-chain event structs that let off-chain clients track lifecycle transitions.
Program Entry Point
#[program] block readable and lets you unit-test the helper methods directly.
Escrow State
| Field | Type | Purpose |
|---|---|---|
maker | Address | The wallet that created the escrow and deposited token A |
mint_a | Address | The mint of the token deposited by the maker |
mint_b | Address | The mint of the token the maker wants in return |
maker_ta_b | Address | Token account that should receive token B when the escrow is taken |
receive | u64 | Amount of token B the maker requires |
bump | u8 | Canonical PDA bump stored so the escrow can sign CPIs without re-deriving it |
set_inner enables the set_inner(...) method, which populates all fields at once from a plain EscrowInner value — useful because the account is allocated empty by init and must be filled in the handler. #[seeds(b"escrow", maker: Address)] generates a Escrow::seeds(maker) function that returns the canonical ProgramDerivedAddress for a given maker pubkey, used in address = Escrow::seeds(maker.address()) constraints elsewhere.
PDA Derivation
On the client side, the escrow address is found the same way:Instruction 1: Make
Themake instruction creates the Escrow PDA, opens two token accounts idempotently (one for the maker’s token B receipts, one vault for token A), and transfers the deposit from the maker’s existing token A account into the vault.
Accounts
init, payer = maker, address = Escrow::seeds(maker.address())— allocates the PDA, charges rent tomaker, and validates that the provided address matches the derived seeds. If the address is wrong the instruction fails before any state is written.init(idempotent)— creates the token account if it does not already exist, and succeeds silently if it does. Quasar validates that an existing account has the correct mint and authority, so this is safe even if the maker callsmaketwice (for example after a partial failure).token(mint = mint_b, authority = maker, ...)—maker_ta_breceives token B, owned by the maker.token(mint = mint_a, authority = escrow, ...)— the vault is owned by theescrowPDA so only the program itself can move tokens out of it.
Implementation
bumps.escrow is the canonical bump that find_program_address returned when Quasar validated the address = constraint. Storing it in the escrow state avoids recomputing it during take and refund CPIs.
Make Event
emit!(MakeEvent { ... }) writes the event to the transaction log in a format that off-chain indexers can parse. The discriminator = 0 byte is prepended automatically.
Instruction 2: Take
Any taker can satisfy the escrow by callingtake. The instruction verifies the escrow’s state, transfers token B from the taker to the maker, then transfers token A from the vault to the taker and closes both the vault token account and the escrow PDA.
Accounts
escrow:
has_one(maker)— themakeraccount in this instruction must matchescrow.maker.has_one(maker_ta_b)— themaker_ta_btoken account must match the address recorded inescrow.maker_ta_b.constraints(escrow.receive > 0)— a custom inline constraint that prevents taking a zero-receive escrow.close(dest = taker)— after the handler succeeds, Quasar will zero out the escrow account data and transfer its lamports totaker.
Implementation
escrow.bump. invoke_signed passes these seeds to the runtime, which verifies them against the escrow’s address before granting signing authority.
Instruction 3: Refund
If no taker appears, the maker callsrefund to reclaim their token A and recover the lamports locked in the escrow PDA. The instruction mirrors take’s CPI logic but the maker is the beneficiary.
Accounts
close(dest = maker) closes the escrow PDA and returns its rent lamports to the maker. has_one(maker) ensures only the original maker can refund — a taker cannot drain the vault by calling refund with a different maker_ta_a.
Implementation
Escrow Lifecycle
Maker calls make
The maker provides
deposit (how much token A to lock) and receive (how much token B they want). Quasar creates the Escrow PDA, opens the vault token account (owned by the escrow PDA), and transfers deposit lamports-worth of token A into the vault. A MakeEvent is emitted.Taker calls take (happy path)
Any taker who agrees to the price calls
take. Quasar validates the has_one constraints, then the handler sends receive tokens of token B to the maker and withdraws all of token A from the vault to the taker. The vault token account is closed and the escrow PDA’s rent lamports are returned to the taker. A TakeEvent is emitted.Events Summary
MakeEvent
Emitted when a maker creates a new escrow. Carries
escrow, maker, mint_a, mint_b, deposit, and receive — everything an indexer needs to reconstruct the offer.TakeEvent
Emitted when a taker fills the escrow. Carries only the
escrow address; all other information can be fetched from the corresponding MakeEvent.RefundEvent
Emitted when the maker cancels and reclaims their tokens. Carries the
escrow address so indexers can mark the offer as closed.