Anchor, Deserialization and Memory Copy

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.

Salah Ismail

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:

  1. De-serialize: Convert raw account data into Rust structs
  2. Execute: Run your instruction with these structs as variables
  3. Serialize: Write the modified structs back to the accounts

The issue resides on the last step, when data is written back to the account.:

  1. First, woopool_to is serialized and its data written back to the USDC WooPool account.
  2. 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: 

  1. Build, then Deploy the program.
  2. 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.

Follow @AdevarLabs on X / LinkedIn - Ship Safely!