This article will use this report from WooFi Sherlock contest as the underlying example.
#[derive(Accounts)]
pub struct Swap<'info> {
// ... all the account definitions ...
woopool_from: Box<Account<'info, WooPool>>,
woopool_to: Box<Account<'info, WooPool>>,
woopool_quote: Box<Account<'info, WooPool>>,
// ... more accounts ...
}
pub fn handler(ctx: Context<Swap>, from_amount: u128,
min_to_amount: u128) -> Result<()>
{
// ... logic
In the above snippet, the Swap struct is the input context for the swap instruction represented by the handler function. This allows users to swap a from token against a to token, with quote being the unit of the exchange.
E.g, I want to swap ABC for XYZ, but there is no ABC/XYZ oracle. But there exists two oracles ABC/USDC and XYZ/USDC. Then I can sell ABC for USDC, and buy XYZ with USDC. At the end, it is similar to exchanging ABC for XYZ, but with an extra step; and USDC is the quote token here.
Happy Path
Going back to our swap instructions, users can describe their swap by providing the right accounts in woopool_from, woopool_to and woopool_quote. If we keep our previous example:
- 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 amount 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
But what happens if the swap route is direct ? Let say that our user wants to swap ABC (from token) for USDC (to token), then USDC can also be the quote token, meaning that the user will provide to the instruction:
- woopool_from = ABC WooPool
- woopool_to = USDC WooPool
- woopool_quote = USDC WooPool
We can see here that woopool_to and woopool_quote relate to the same account. And this is where things get interesting: how are those accounts provided to the instruction?
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_to is serialized and its data written back to the USDC WooPool account.
- Then, woopool_quote is serialized and is written back to the USDC WooPool account, overwriting the previous operation!


So, what is the solution here?
One way to solve the issue is to apply the changes to only one of the variables, either woopool_to or woopool_quote for both operations that affect the USDC WooPool.
Rather than updating token balance on woopool_to and distributing fees to woopool_quote, we can do both operations on woopool_to:
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() {
woopool_to.sub_reserve(swap_fee).unwrap();
woopool_to.add_unclaimed_fee(swap_fee).unwrap();
} else {
// ...
}
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
You can try this yourself using this Solana Playground
Instructions:
- Build, then Deploy the program.
- Right click on ‘tests/deploy.test.ts’ and “Test”
Overall, Anchor is powerful, but it's not magic. Understanding its serialization mechanics is crucial to building safe, predictable Solana programs. By managing memory correctly and consolidating state changes, you avoid pitfalls that can silently break your logic.
Want to learn more? Stay tuned for more deep dives, real-world bugs, and smart contract best practices.