Dual reserves, delayed withdrawals, physical gold redemptions โ this wasn't a typical staking audit. Here's what broke and what to look for next time.
I recently spent time auditing a gold-backed liquid staking protocol built with Anchor on Solana. Unlike standard DeFi staking where you deposit token A and receive token B, this system is backed by physical gold โ which introduces a whole layer of accounting complexity that most staking auditors never encounter.
The protocol had a dual-reserve architecture, a delayed withdrawal system for handling physical gold redemptions, and an exchange rate model that ties together multiple token pools. It taught me patterns that apply to any protocol that splits reserves across multiple accounts and tries to coordinate between them.
Here's what I learned, and what to look for when you audit something similar.
Most liquid staking protocols have a single vault. You deposit GOLD, you get stGOLD, the vault tracks everything. Simple.
This protocol split its backing into two separate reserve accounts:
The rate sees the full picture, but individual operations only touch one pool. Instant unstaking draws from liquid. Delayed withdrawal fulfillment draws from staked. Yield can be routed to either.
The audit pattern: Whenever you see a protocol that computes a value from aggregated state but operates on partitioned state, you should immediately check every code path for consistency. Does every function that uses the rate also have access to all the reserves the rate reflects? If not, there's a bug hiding somewhere.
This was the most satisfying finding. The protocol's delayed withdrawal flow works like this:
Step 1 โ User calls request_withdrawal. Their stGOLD is locked in a per-user vault (an ATA owned by a PDA). Step 2 โ Admin calls fulfill_withdrawal. Burns the locked stGOLD, pays GOLD to the user, closes the vault.
The vault closure is the key. SPL Token's close_account instruction has an absolute requirement: the token account balance must be exactly zero. Not approximately zero. Not "close enough." Exactly zero.
But the vault is a standard Associated Token Account with a publicly derivable address. Anyone can compute it. Anyone can send tokens to it. Including an attacker.
close_account fails because balance โ 0 โ entire transaction reverts atomically
I wrote a full PoC using LiteSVM that confirms the transaction revert. The test passes โ the bug is real.
The lesson: Any time a Solana program creates a token account that will later be closed, ask yourself: can anyone else send tokens to this account? If the address is deterministic (ATAs always are, PDAs usually are), the answer is yes. And if close_account assumes zero balance, you have a griefing vector.
This pattern applies broadly โ escrow accounts closed after settlement, vault accounts in withdrawal flows, temporary accounts in swap/bridge operations, any ATA where the authority is a PDA.
The fix: Burn the actual vault balance (not a stored amount) before closing, or transfer any remaining dust to a protocol-controlled account before calling close_account.
This one took longer to pin down because the individual pieces of logic are all correct in isolation. The bug only emerges from the interaction between two functions.
request_withdrawal computes the gold equivalent using the convert() function, which sums both reserves:
This value is stored in the withdrawal request and never updated. When the admin later calls fulfill_withdrawal, the GOLD is paid exclusively from staked reserves. The FulfillWithdrawal accounts struct doesn't even include liquid reserves โ there's no way for it to access that pool.
When does this trigger? At the default 90/10 split, the protocol prices withdrawals against 100% of reserves but can only pay from 90%. If two users (holding 60% and 40% of supply) both request delayed withdrawals, the first fulfillment succeeds but drains staked reserves below what the second needs. The second user is permanently locked.
The lesson: In dual-reserve or multi-pool architectures, trace the lifecycle of every value. Where is it computed? (which pools contribute?) Where is it stored? (snapshot or live?) Where is it used? (which pools are accessed?) If the set of pools at computation doesn't match the set at consumption, you have a mismatch.
The protocol had two staking systems โ a legacy fixed-staking module and the newer liquid staking. Both deposited into the same vault (same ATA, same authority). Only liquid staking tracked its balance via a liquid_amount counter.
The problem: when fixed stakers claimed monthly rewards or unstaked, the tokens left the vault โ but liquid_amount was never decremented. Over time, liquid_amount overstated the vault's real balance by exactly the total rewards paid to fixed stakers.
I wrote a pure-arithmetic PoC (no blockchain runtime needed) simulating a 10-user scenario where the shortfall compounds to 22% of the liquid pool, making it insolvent.
The lesson: Shared vaults are a classic insolvency vector. Any time two products share a token account but maintain separate accounting, check whether withdrawals from one product correctly update the other's counter. The audit question: does every token outflow from the vault have a corresponding decrement in every accounting variable that references that vault?
Auditing Anchor programs has its own rhythm compared to Solidity or Move. Here are patterns I now check on every Solana audit:
Any close_account CPI is a potential griefing target if the account address is predictable. Check whether the burn/transfer before close uses the stored amount or the actual balance. Stored amounts are vulnerable; actual balances are safe.
A program might derive a PDA for one purpose but use a different PDA (or no PDA) as the token account authority. Check that the authority in mint_to, burn, and transfer_checked CPIs matches the PDA that actually controls the account on-chain.
init vs init_if_needed on One-Shot AccountsReserve accounts initialized with init can only be created once. If initialize_reserves lacks access control, an attacker can front-run deployment and create reserves with a fake mint, permanently bricking the protocol.
saturating_sub Hiding Bugssaturating_sub silently floors at zero instead of reverting. Convenient but dangerous in accounting code. If a.saturating_sub(b) should never underflow in correct operation, use checked_sub with an explicit error instead. saturating_sub masks the exact bugs you want to catch during testing.
Many Solana programs implement a two-phase flow (request โ fulfill) without a cancel instruction. If the fulfill step can fail for any reason โ insufficient reserves, dust griefing, oracle staleness โ the user's funds are permanently locked. Always ask: what happens if fulfill reverts? Is there a recovery path?
For this audit I used LiteSVM โ a lightweight Solana VM for Rust-based integration tests. It lets you load compiled .so files and execute full transactions without spinning up a validator.
The pattern that worked well for PoCs:
For arithmetic bugs (like the shared vault insolvency), I used pure Rust unit tests that simulate the on-chain logic with plain u64 variables. No blockchain runtime needed โ just mirror the exact arithmetic from each instruction handler and show the invariant violation.
If I audit another dual-reserve staking protocol, here's what I check first:
saturating_sub in accounting paths โ Should these be checked_sub instead?The most dangerous bugs in dual-reserve protocols are not in the complex math. The math is usually right โ convert() was correct, the exchange rate formula was sound, the rounding was conservative.
The danger is in the seams โ the places where one correct function hands off to another correct function, and the assumptions don't match. The rate function assumes it sees all the gold. The fulfillment function assumes it can only see staked gold. Both are correct in isolation. Together, they create a gap where user funds fall through and never come back.
When you audit multi-pool systems, don't just verify each function. Verify the handoffs. ๐ช
๐ Solana, Anchor, and Rust ecosystems evolve fast โ if any pattern, code snippet, or recommendation in this post is outdated, incorrect, or no longer applies, please reach out on X so I can update it.