
When using the Anchor's macro #[derive(Accounts)], a lot goes on under the hood. Anchor helps abstract away boilerplate and security vulnerabilities, but it can also introduce subtle pitfalls. In this post, we explore a surprising bug caused by how Anchor handles deserialization and memory copying, especially when multiple variables refer to the same account. Using a real example from the WooFi Sherlock contest, we break down what goes wrong, why, and how to fix it.
The Example
This article uses a report from the WooFi Sherlock contest as the underlying example.
#[derive(Accounts)]
pub struct Swap<'info> {
woopool_from: Box<Account<'info, WooPool>>,
woopool_to: Box<Account<'info, WooPool>>,
woopool_quote: Box<Account<'info, WooPool>>,
}
pub fn handler(ctx: Context<Swap>, from_amount: u128, min_to_amount: u128) -> Result<()> {
// ... logic
}The Swap struct is the input context for the swap instruction. This allows users to swap a from token against a to token, with quote being the unit of the exchange.
Example: I want to swap ABC for XYZ, but there is no ABC/XYZ oracle. But there exist two oracles: ABC/USDC and XYZ/USDC. So I sell ABC for USDC, and buy XYZ with USDC. USDC is the quote token.
Happy Path
For a normal swap:
- woopool_from = ABC WooPool
- woopool_to = XYZ WooPool
- woopool_quote = USDC WooPool
During the swap operation, the woopool_quote receives the fees, while woopool_from and woopool_to get their token amounts updated.
woopool_from.add_reserve(from_amount).unwrap();
woopool_to.sub_reserve(to_amount).unwrap();
// record fee into account
woopool_quote.sub_reserve(swap_fee).unwrap();
woopool_quote.add_unclaimed_fee(swap_fee).unwrap();Unhappy Path: The Bug
What happens if the swap route is direct? Say the user wants to swap ABC (from token) for USDC (to token) - then USDC is also the quote token:
- woopool_from = ABC WooPool
- woopool_to = USDC WooPool
- woopool_quote = USDC WooPool
We can see that woopool_to and woopool_quote relate to the same account. And this is where things get interesting.
When Anchor processes your accounts, it follows a three-step process:
- De-serialize - convert raw account data into Rust structs
- Execute - run your instruction with these structs as variables
- Serialize - write the modified structs back to the accounts
The issue resides on the last step, when data is written back to the account:
- First,
woopool_tois serialized and its data written back to the USDC WooPool account. - Then,
woopool_quoteis serialized and written back to the USDC WooPool account - overwriting the previous operation!
The net result is that woopool_to's write is completely lost, because woopool_quote (which was deserialized from the original state, before the fee operation) overwrites it.
The Solution
One way to solve the issue is to apply the changes to only one of the variables when the accounts are the same:
woopool_from.add_reserve(from_amount).unwrap();
woopool_to.sub_reserve(to_amount).unwrap();
// record fee into account
if ctx.accounts.woopool_to.key() == ctx.accounts.woopool_quote.key() {
// Use woopool_to for both operations since they reference the same account
woopool_to.sub_reserve(swap_fee).unwrap();
woopool_to.add_unclaimed_fee(swap_fee).unwrap();
} else {
woopool_quote.sub_reserve(swap_fee).unwrap();
woopool_quote.add_unclaimed_fee(swap_fee).unwrap();
}This solution works because:
- We only modify one copy when dealing with duplicate accounts
- All changes happen on the same in-memory struct
- When Anchor writes it back, all our changes are preserved
Why This Matters
This bug pattern can appear anywhere in Anchor programs where:
- Multiple accounts of the same type are accepted
- The instruction logic is designed for distinct accounts
- There's no explicit check preventing the same account from being passed multiple times
For example:
- Token swaps (from/to/quote being the same token)
- Liquidity operations (pool A and pool B being the same pool)
- Fee collection (destination matching the source)
Key Takeaways
Anchor is powerful, but it's not magic. Understanding its serialization mechanics is crucial to building safe, predictable Solana programs.
When you write Anchor code that accepts multiple accounts of the same type:
- Always consider the case where two accounts are the same
- Add explicit key equality checks before performing operations that would create conflicting writes
- Consolidate state changes - make all modifications through a single variable when accounts overlap
By managing memory correctly and consolidating state changes, you avoid pitfalls that can silently break your logic.
