โ† Back to Blog

How to Audit a Gold-Backed Staking Protocol on Solana

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.

โœ 0xTheBlackPanther ๐Ÿ“… Mar 2026 โฑ 12 min read ๐Ÿท Solana ยท Anchor ยท Staking ยท Audit

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.


The Architecture: Two Pools, One Rate

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:

Dual-Reserve Architecture Staked Reserves (~90%) โ€” the bulk of deposits, representing physical gold backing
Liquid Reserves (~10%) โ€” a smaller buffer for instant withdrawals

The exchange rate sees both pools combined:
rate = (staked_reserves + liquid_reserves) / stgold_supply

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.


๐Ÿ”ด FINDING 1 The 1-Token Attack That Locks Millions

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.

โšก Attack Sequence โ€” Cost: 1 token unit
1 Victim requests withdrawal โ€” 1,000,000 stGOLD locked in vault
2 Attacker sends 1 stGOLD dust to the vault (plain SPL transfer, costs nothing)
3 Admin calls fulfill โ€” burns 1,000,000 stGOLD (the recorded amount), but 1 dust remains
4 close_account fails because balance โ‰  0 โ€” entire transaction reverts atomically
5 Victim's stGOLD is permanently locked. No cancel instruction exists.

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.


๐ŸŸ  FINDING 2 Dual-Reserve Rate Mismatch

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:

# request_withdrawal โ€” prices against BOTH pools gold_equivalent = stgold_amount ร— (staked + liquid) / supply

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.

# fulfill_withdrawal โ€” can only pay from ONE pool // Accounts struct has:
  staked_reserves: Account<TokenAccount>   // โœ… included
  // liquid_reserves                     // โŒ NOT included

When gold_equivalent > staked_reserves.amount โ†’ permanent revert

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.


๐ŸŸก FINDING 3 Shared Vault Insolvency in Legacy Staking

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.

# The gap is deterministic and grows every month shortfall = total_fixed_principal ร— monthly_rewards_bps ร— months / 10,000

// And the admin CANNOT fix it โ€”
// the only deposit function (reward()) increments liquid_amount
// by the same amount it adds to the vault.
// The gap is permanent.

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?


Solana-Specific Audit Patterns

Auditing Anchor programs has its own rhythm compared to Solidity or Move. Here are patterns I now check on every Solana audit:

PATTERN 01
Close Account Griefing

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.

PATTERN 02
PDA Authority Mismatches

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.

PATTERN 03
init vs init_if_needed on One-Shot Accounts

Reserve 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.

PATTERN 04
saturating_sub Hiding Bugs

saturating_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.

PATTERN 05
The "No Cancel" Pattern

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?


The Testing Stack

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:

PoC recipe with LiteSVM 1. Create a self-contained test file (no shared fixtures with borrow checker issues)
2. Build instruction data manually using Anchor discriminators:
    sha256("global:<instruction_name>")[0..8]
3. Set up all accounts independently โ€” mints, ATAs, PDAs
4. Execute the attack sequence and assert on-chain state

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.


Priority Checklist for Dual-Reserve Protocols

If I audit another dual-reserve staking protocol, here's what I check first:


Closing Thought

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.

Protocol type: Gold-backed liquid staking (Anchor / Solana)
Architecture: Dual-reserve (staked + liquid), delayed withdrawal system
Findings: 3 core findings โ€” close account griefing, rate mismatch, shared vault insolvency
Testing: LiteSVM (integration PoCs) + pure Rust (arithmetic PoCs)
Patterns: 5 Solana-specific audit patterns + 7-item priority checklist
Author: @thepantherplus ยท pantheraudits.com
โ† Back to Blog