Skip to content

security: fix broken take_offer invariant + add missing RefundOffer instruction - #668

Merged
dev-jodee merged 4 commits into
solana-foundation:mainfrom
NikkiAung:fix/escrow-refund-offer
Aug 12, 2026
Merged

security: fix broken take_offer invariant + add missing RefundOffer instruction#668
dev-jodee merged 4 commits into
solana-foundation:mainfrom
NikkiAung:fix/escrow-refund-offer

Conversation

@NikkiAung

Copy link
Copy Markdown
Contributor

Summary

Escalated the same security-audit methodology from #667 (missing signer checks in basics/) to tokens/escrow — native, pinocchio, and anchor. Escrow/vault programs are the highest-value target for this kind of audit: they hold other people's funds in program custody, and this repo's version is what learners copy into real token-swap escrows.

Bug (native only): broken post-transfer invariant lets a third party permanently freeze any offer

take_offer's post-transfer sanity check compared the maker's token-B balance against the wrong variable:

assert_eq!(maker_amount_b, taker_amount_a_before_transfer + offer.token_b_wanted_amount); // BUG

taker_amount_a_before_transfer is a copy-paste of the line above, only half-edited — it should be maker_amount_b_before_transfer. The real SPL Token CPI always moves the correct amount, so this assert only happened to pass when the maker's token-B balance was exactly 0 before the trade. That's not guaranteed, and is trivially breakable by any third party, no cooperation needed: create the maker's token-B ATA (permissionless) and send it 1 base unit (also permissionless — no signature required from the recipient) before a take_offer call. From that point on, every take_offer for that offer panics and reverts.

Gap (all three implementations): no way for a maker to reclaim their deposit

This is what turns the bug above from "annoying" into permanent fund loss: there was no RefundOffer/cancel instruction anywhere — not in native, not in pinocchio, not even in the anchor reference version. Only MakeOffer/TakeOffer existed. Once an offer is bricked (by the bug above, or simply because the counterparty never shows up — the ordinary non-adversarial case), the maker's deposited tokens were locked in the vault with no recovery path.

Fixes

  • One-line fix to the comparison in native/program/src/instructions/take_offer.rs.
  • New RefundOffer instruction in all three implementations: only the maker (verified against the offer's stored maker field) can call it. Returns the vault's current token-A balance to the maker's own account, closes the vault and offer accounts, rent to the maker. Structurally mirrors each implementation's existing take_offer vault-drain-and-close logic — same seeds, same CPI shape — just redirecting the destination and dropping the token-B leg entirely (a refund never touches token B).

Test plan

Every new test was verified to fail against the unpatched/pre-feature code first (confirming it actually exercises the bug/gap, not a false negative), then pass after the fix — same discipline as #667:

  • native: regression test pre-funds the maker's token-B account before Take Offer to reproduce the exact condition that broke the old assert (confirmed panic at take_offer.rs:171 before the fix); RefundOffer happy-path and non-maker-cannot-refund tests. 6/6 passing.
  • pinocchio: same two RefundOffer tests (never had the assert bug — its take_offer has no equivalent check). 4/4 passing.
  • anchor: same two RefundOffer tests added to litesvm.test.ts. 4/4 passing. (The separate validator-based escrow.test.ts — pre-existing, untouched — couldn't be executed in this sandbox due to a local solana-test-validator startup issue unrelated to this change; the identical on-chain logic is already fully exercised via litesvm.test.ts against the same compiled program.)
  • cargo fmt --check and cargo clippy -- -D warnings clean for the root-workspace crates (native + pinocchio); prettier --check clean for all touched TS files. (tokens/escrow/anchor is its own nested Cargo workspace, outside the root workspace cargo fmt/clippy cover — consistent with the rest of the repo's */anchor/ programs.)
  • Added #[allow(clippy::enum_variant_names)] to native's EscrowInstruction enum — adding the third RefundOffer variant tripped the lint (it only fires at ≥3 variants), and the shared Offer postfix is intentional domain vocabulary matching the existing MakeOffer/TakeOffer naming, not something to rename.

Incidental fix

Extended native's and pinocchio's test createValues() helper to actually respect all the overridable defaults its own type signature (TestValuesDefaults) already promised — it previously only read programId/id from the passed defaults and silently regenerated maker/taker/mint keypairs regardless, which made it impossible to create a second offer reusing the same already-funded accounts and already-deployed program (needed for the new tests, which create additional offers under the same setup).

…nstruction

Escalated the same security-audit methodology from PR solana-foundation#667 (missing
signer checks in basics/) to tokens/escrow - native, pinocchio, and
anchor. Escrow/vault programs are the highest-value target for this
kind of audit: they hold other people's funds in program custody, and
this repo's version is what learners copy into real token-swap
escrows.

Bug (native only): take_offer's post-transfer invariant check
compared the maker's token-B balance against the wrong variable -
`taker_amount_a_before_transfer` instead of
`maker_amount_b_before_transfer` (a copy-paste of the line above,
half-edited). The real SPL Token CPI always moves the correct amount,
so the assert only happened to pass when the maker's token-B account
balance was exactly 0 before the trade. That's not guaranteed, and is
trivially breakable by a third party: anyone can create the maker's
token-B ATA (permissionless) and send it 1 base unit (also
permissionless, no signature required from the recipient) before a
take_offer call. From that point on, every take_offer for that offer
panics and reverts.

Gap (all three implementations): there was no way for a maker to
reclaim a deposited offer - not in native, not in pinocchio, not even
in the anchor reference version. Only MakeOffer/TakeOffer existed.
This is what turns the assert bug from "annoying" into "permanent
fund loss": once an offer is bricked (or its counterparty simply
never shows up, the ordinary non-adversarial case), the maker's
tokens were locked in the vault with no recovery path.

Fixes:
- One-line fix to the comparison in native's take_offer.rs.
- New RefundOffer instruction in all three implementations: only the
  maker (verified against the offer's stored `maker` field) can call
  it. Returns the vault's current token-A balance to the maker's own
  account, closes the vault and offer accounts, rent to the maker.
  Structurally mirrors each implementation's existing take_offer
  vault-drain-and-close logic (same seeds, same CPI shape), just
  redirecting the destination and dropping the token-B leg.

Every new test was verified to fail before its corresponding fix
(confirming it actually exercises the bug, not a false negative) and
pass after:
- native: regression test pre-funds the maker's token-B account
  before Take Offer to reproduce the exact condition that broke the
  old assert; RefundOffer happy-path and non-maker-cannot-refund
  tests.
- pinocchio: same two RefundOffer tests (never had the assert bug).
- anchor: same two RefundOffer tests added to litesvm.test.ts.

Also extended native's and pinocchio's test `createValues()` helper
to actually respect all the overridable defaults its own type
signature (TestValuesDefaults) already promised - it previously only
read `programId`/`id` from the passed defaults and silently
regenerated maker/taker/mint keypairs regardless, which made it
impossible to create a second offer against the same already-funded
accounts and already-deployed program.
@NikkiAung
NikkiAung requested a review from dev-jodee as a code owner August 5, 2026 01:45
@greptile-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR corrects native escrow settlement accounting and adds maker-authorized offer refunds across the Anchor, native, and Pinocchio implementations.

  • Adds refund instructions that return deposited token A, close the canonical vault, and reclaim offer rent.
  • Adds canonical-vault, token-program, maker, and destination validation appropriate to each implementation.
  • Adds regression coverage for refunds, unauthorized callers, substitute accounts, and pre-existing destination balances.
  • Updates test-value helpers so caller-provided identities and mints are retained.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
tokens/escrow/native/program/src/instructions/refund_offer.rs Implements maker-only refunds and now validates the real token program, canonical vault, canonical destination, and offer PDA before transferring or closing accounts.
tokens/escrow/pinocchio/program/src/instructions/refund_offer.rs Implements maker-only refunds with canonical ATA checks that prevent substitute vault or destination accounts from being used.
tokens/escrow/anchor/programs/escrow/src/instructions/refund_offer.rs Adds an Anchor refund flow whose account constraints bind the signer, offer, token mint, destination ATA, vault ATA, and token interface.
tokens/escrow/native/program/src/instructions/take_offer.rs Corrects the post-transfer balance invariant and requires the canonical offer vault.
tokens/escrow/pinocchio/tests/utils.ts Makes rejection assertions fail on unexpected success and preserves caller-supplied mint overrides during address ordering.
tokens/escrow/native/tests/utils.ts Preserves supplied identities and mints while regenerating only missing values needed to satisfy deterministic mint ordering.

Reviews (4): Last reviewed commit: "fix(#668): address dev-jodee review - va..." | Re-trigger Greptile

Comment on lines +66 to +76
&token_instruction::transfer(
token_program.key,
vault.key,
maker_token_account_a.key,
offer_info.key,
&[offer_info.key],
vault_amount_a,
)?,
&[vault.clone(), maker_token_account_a.clone(), offer_info.clone(), token_program.clone()],
&[offer_signer_seeds],
)?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Unchecked token program strands vault

When the maker supplies another executable account as token_program, both CPIs can report success without transferring or closing the vault, after which the handler destroys the offer account and leaves its deposited tokens without a recovery path.

How this was verified: The caller-supplied program key is used for both CPIs, and the offer is then closed without independently checking that the vault was drained.

Knowledge Base Used: Tokens Directory Overview

Comment on lines +28 to +35
export const expectRevert = async (promise: Promise<unknown>) => {
try {
await promise;
throw new Error('Expected a revert');
} catch {
return;
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Revert helper swallows success

When the supplied promise resolves, expectRevert throws Expected a revert inside the try and immediately catches that same error, so the new non-maker test passes even when the refund unexpectedly succeeds.

Suggested change
export const expectRevert = async (promise: Promise<unknown>) => {
try {
await promise;
throw new Error('Expected a revert');
} catch {
return;
}
};
export const expectRevert = async (promise: Promise<unknown>) => {
let reverted = false;
try {
await promise;
} catch {
reverted = true;
}
if (!reverted) {
throw new Error('Expected a revert');
}
};

Knowledge Base Used: Tokens Directory Overview

Comment thread tokens/escrow/native/tests/utils.ts Outdated
Comment on lines +174 to +181
// Making sure tokens are in the right order
const mintAKeypair = await generateKeyPairSigner();
let mintBKeypair = await generateKeyPairSigner();
while (isLessThan(addressEncoder.encode(mintBKeypair.address), addressEncoder.encode(mintAKeypair.address))) {
mintBKeypair = await generateKeyPairSigner();
let mintAKeypair = defaults?.mintAKeypair;
let mintBKeypair = defaults?.mintBKeypair;
if (!mintAKeypair || !mintBKeypair) {
mintAKeypair = mintAKeypair ?? (await generateKeyPairSigner());
mintBKeypair = mintBKeypair ?? (await generateKeyPairSigner());
while (isLessThan(addressEncoder.encode(mintBKeypair.address), addressEncoder.encode(mintAKeypair.address))) {
mintBKeypair = await generateKeyPairSigner();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Partial mint override gets discarded

When only mintBKeypair is supplied and the generated mint A sorts after it, the ordering loop replaces the caller-provided mint B. Tests requesting reuse of that mint then derive accounts and offers for a different mint, which can exercise the wrong setup; the Pinocchio helper has the same behavior.

…ests

Two separate tsc --noEmit errors, both only caught by CI's typecheck
step (tsx strips types without checking, so the local mocha run
passed regardless):

- native: svm.getAccount() returns a union type where .data only
  exists on the exists:true variant. Four new call sites decoded
  .data without narrowing first - added the same
  assert(x.exists, ...) guard the rest of this file already uses
  before every decode.
- anchor: the non-maker-refund test intentionally passes mismatched
  accounts (that's what it's proving the program rejects), which
  trips TypeScript's excess-property check against the IDL-derived
  account type. Split it into a separately-typed
  Record<string, PublicKey> and pass it through an explicit `as any`,
  since strict typing doesn't apply to a deliberately-invalid input.
return Err(ProgramError::InvalidSeeds);
}

let vault_amount = TokenAccount::from_account_view(vault)?.amount();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Substitute vault strands deposit

When the maker signs a refund with another token-A account owned by the offer PDA, the handler drains and closes that substitute account and then destroys the offer. Because it never verifies that vault is the offer's canonical ATA, the genuine funded vault remains inaccessible.

Knowledge Base Used: Tokens Directory Overview

Four real issues, all verified before/after:

1. native/pinocchio refund_offer.rs (P1, security): neither checked
   that `vault` is actually the offer's canonical ATA. A substitute
   token-A account with owner = the offer PDA (creatable by anyone,
   no cooperation needed from the PDA - owner is just a stored field,
   never requires the owner to sign at account-creation time) could
   be passed instead, draining that decoy and then closing the real
   offer anyway - permanently stranding the genuine vault's funds.
   Added the same assert_is_associated_token_account check make_offer
   already does at vault creation time. New adversarial test (native)
   creates exactly such a decoy and confirms refund now rejects it -
   verified it fails without the fix, passes with it.

2. native refund_offer.rs (P1, security): the caller-supplied
   token_program was used directly as the CPI target with no check
   that it's the real SPL Token program. A substituted fake program
   could report success on both the transfer and close without
   moving anything, after which the offer gets destroyed anyway.
   Added spl_token_interface::check_program_account(), the same
   function the SPL crate's own instruction builders call internally
   - now enforced explicitly up front instead of implicitly deep in
   the CPI. (Pinocchio's Transfer/CloseAccount builders already
   hardcode the real program ID rather than trusting the passed
   account, so this class of bug doesn't apply there.)

3. pinocchio/tests/utils.ts expectRevert (P2): the re-throw meant to
   signal "the operation unexpectedly succeeded" happened inside the
   same try block as the operation itself, so its own catch swallowed
   it - the helper returned normally regardless of whether the
   promise resolved or rejected. This made "Refund Offer rejects a
   non-maker signer" pass vacuously (verified: it kept passing even
   before this fix, silently proving nothing). Restructured per
   Greptile's suggestion so success is tracked in a flag checked after
   the try/catch, outside where the operation's own rejection is
   caught. The non-maker test's underlying security check was never
   actually broken - just its ability to prove that.

4. native/pinocchio tests/utils.ts createValues (P2): my earlier fix
   for making defaults overridable had a bug of its own - if the
   caller supplied only one of mintAKeypair/mintBKeypair and the
   ordering check needed a redo, it could regenerate and discard the
   one the caller DID supply. Rewrote so only the mint not present in
   the original defaults is ever regenerated; if both were supplied
   and are genuinely out of order, throws instead of silently picking
   one to discard.
@NikkiAung

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all 4 findings were real and fixed in 34cbf2a:

  1. Substitute vault (P1, native + pinocchio) — added the same assert_is_associated_token_account check make_offer already does at creation time. Added a new adversarial test (native) that creates exactly the decoy account you described and confirmed it fails without the fix, passes with it.
  2. Unchecked token program (P1, native) — added spl_token_interface::check_program_account(). Pinocchio's CPI builders already hardcode the real program ID rather than trusting the passed account, so that one doesn't apply there.
  3. expectRevert swallowing success (P2) — used your suggested fix exactly. Confirmed this was a real gap: the non-maker-refund test kept "passing" even before this fix, proving it wasn't actually checking anything. The underlying program logic was always correct — just the test's ability to prove it.
  4. Partial mint override discarded (P2) — fixed in both native and pinocchio's createValues, with a thrown error instead of a silent discard if a caller supplies both mints already out of order.

@NikkiAung

Copy link
Copy Markdown
Contributor Author

Hey @dev-jodee — bumping this for review when you get a chance. CI is green and Greptile came back clean (5/5, no blocking findings). Thanks!

Comment thread tokens/escrow/native/program/src/instructions/take_offer.rs
Comment thread tokens/escrow/pinocchio/program/src/instructions/refund_offer.rs
Comment thread tokens/escrow/pinocchio/program/src/instructions/refund_offer.rs
…ion substitution in take_offer and refund_offer

dev-jodee's review found the same vault-substitution class fixed in
refund_offer.rs during the prior review round was never applied to
take_offer.rs (native + pinocchio), and that pinocchio's
refund_offer.rs only validated the vault, not the maker's own refund
destination account.

- native take_offer.rs: add assert_is_associated_token_account for
  vault before it's used as the transfer source and CPI-signing
  authority, matching the check already present in refund_offer.rs.
- pinocchio take_offer.rs: add the equivalent find_program_address
  canonical-ATA check for vault, matching refund_offer.rs's existing
  pattern.
- pinocchio refund_offer.rs: add the same canonical-ATA check for
  maker_token_account_a, so a substitute destination account can't be
  passed for the refund.

Added adversarial tests for all three (plus a "Refund Offer rejects a
substitute vault account" test for pinocchio, which turned out to
have zero regression coverage for that already-existing check).
Verified each new test fails against the pre-fix code (temporarily
reverted via a captured patch, not a blind revert) and passes after -
confirmed the two brand-new checks (take_offer's vault, refund's
maker_token_account_a) each catch a real gap, while the pre-existing
refund vault check predictably passes either way since it was already
fixed in an earlier commit on this branch.

Anchor's take_offer.rs was already safe: its `vault` field carries
`associated_token::mint/authority/token_program` constraints, so
Anchor validates the canonical ATA at the type level before the
instruction body runs - no equivalent gap there.
@NikkiAung

NikkiAung commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@dev-jodee thanks for catching this — both findings were real and fixed in 0c7299a:

  1. Vault trusted without a canonical check in take_offer.rs (native + pinocchio) — this is the same vault-substitution class fixed in refund_offer.rs last round, but it was never applied to take_offer.rs. Added assert_is_associated_token_account (native) / the equivalent find_program_address check (pinocchio) before vault is used as the transfer source and CPI-signing authority.
  2. maker_token_account_a not checked in pinocchio's refund_offer.rs — only the vault was validated; the refund destination itself wasn't. Added the same canonical-ATA check there.

Added adversarial tests for all three (plus one closing a coverage gap: pinocchio's refund_offer.rs vault check from last round never actually had a regression test). Verified each new test fails against the pre-fix code and passes after.

Anchor's take_offer.rs didn't need a change — its vault account already carries associated_token::mint/authority/token_program constraints, so Anchor validates the canonical ATA at the type level before the instruction runs.

CI should be green shortly.

@dev-jodee
dev-jodee merged commit 4b5b22f into solana-foundation:main Aug 12, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants