TL;DR ⚡

  • What you'll learn: How to perform real-world Compute Unit (CU) optimization on a widely-used Solana program (Orca/Whirlpool) to reduce transaction costs and improve efficiency, without compromising security or code logic.

  • Why it matters: Even in highly optimized codebases, strategic refactoring can yield significant CU savings. This post demonstrates a reduction from ~48K to ~34K CUs on the initialize_pool instruction, primarily by making expensive operations (Seed Constraints and CPIs) conditional based on the token type (legacy SPL vs. Token 2022). This highlights how a small time investment can lead to major efficiency gains.

  • Exo Edge: As a leading dev shop, our expertise lies in finding these critical, non-obvious optimizations. We help teams understand and implement these low-cost changes to dramatically improve program performance and user experience on Solana.

Introduction

If you haven’t read Part 1 of this series on CU optimization yet, we recommend starting there. It explains why it is important to pay attention to CUs in your program.

The first step of this series was mainly theoretical, even if we showed some code. In this blog, we will take an existing widely used open source project (orca/whirlpool) and try to optimize an instruction.

1. Let’s start with the results

We took the initialize pool (v2) instruction. Initially, the instruction consumes around 48K CUs. We managed to shrink it to 34K CUs, in the best scenario.

What were the constraints we imposed on ourselves:

  • Keep anchor (we could have used Pinocchio, for example); the idea is not to start again from scratch because you may not have enough time to do so. The idea is to optimize, not to rewrite.

  • Do not make any concessions about security.

  • Do not change the code’s logic (same input, same output before/after optimization)

  • Do not spend more than a day on this optimization.

2. The method - How did we do it?

We - or at least “I”, Effe² - never worked on Orca’s code before this blog. So we needed to read the code cautiously and understand what it does. The second step was to set up the program locally and create tests with LiteSVM in order to measure CUs (we could have done it with TS also of course, but LiteSVM is quite easy to use and integrates very smoothly in a workspace with the program).

2a. Once the first test was written

We saw that the instruction CUs consumption was around 48K, we needed to stabilize CUs.

Why: Because in the instruction there are some seeds constraints. And seeds constraints do not always consume the same amount of CUs, it depends on the public keys involved.

How: Since seeds constraints depends on variables, we needed to make them constants. In other words, we’ve generated a few keypairs, for the vaults, admin, etc. After this step, the instruction was constantly using the same amount of CUs (48K). We were good to go. We’ve created an optimized instruction, in order to be able to compare both.

2b. The next step was to establish where is the CU consumption.

We’ve inserted a lot of solana_program::log::log_compute_units(); in the code to measure everything. By the way, when we have a lot of similar logs, it is sometimes hard to read and to know “where we’re at”. I didn’t want to use “msg!()” because as we’ve seen in the first part, it consumes quite a lot of CUs and it is therefore quite hard to really extract the consumption. Instead, I used at some places solana_program::log::sol_log("bla bla bla"); , this is the most optimized way to log something in your program (string only).

3. Next step - Establish a plan

Note: The code is already quite optimized (as we can expect).

The explanation of (most of) the CU consumption is:

  • Seeds constraints

  • CPI (creating vaults, etc)

  • Specific logic (maths)

Mainly, the CUs consumption takes place in the first 2 items of the above list. So let’s try to optimize them.

3a. Seed constraints

Seed constraints are used only to make sure that the received account address in the instruction is the correct derived address with a given set of variables.

The easiest way of optimizing seed constraint is to remove the constraint ( 🫠 ), so let’s check which seed constraint we have:

  • token badge (x2): this account is used to pre-authorize a token 2022 token, which would have been otherwise refused because of the extension it uses. It is already initialized (if it exists, but not initialized in this instruction anyway).

  • Whirlpool: This is the pool PDA. We can’t remove the seed constraint here, since we need to ensure the pool address is the expected one.

3b. CPI

CPI are used when we need to insert an inner instruction (calling other programs inside / from our instruction). They can be found in 2 different places: in the instruction account structure (with “init”, for example), or explicitly in the handler. Of course, we can’t reduce/optimize the CUs used by another program, so here the only path is to avoid CPI if we can. In some cases, there are multiples instructions that creates +/- the same result (initialize account with spl token), but it was not really helpful here.

4. Optimizations - Enact the plan

4a. Seeds

As stated above, we won’t touch whirlpool account seeds. Let’s take a look at token badge PDA. How / when is this account used? This account is used only when a token is from token 2022 program 👀.

Is it always the case? No. So why would we need to check the address of an account if we don’t use it? We don’t. So let’s remove this constraint in the structure, and manually add it only if it makes sense.

In the handler we added:

if ctx.accounts.token_program_a.key() == SPL_TOKEN_2022_ID {
        let expected_address = Pubkey::find_program_address(
            &[
                b"token_badge",
                whirlpools_config.key().as_ref(),
                token_mint_a.as_ref(),
            ],
            &ctx.program_id,
        )
        .0;
        if ctx.accounts.token_badge_a.key() != expected_address {
            return Err(ErrorCode::TokenBadgeMismatch.into());
        }
    }

This way, token badge PDA is checked only if the token is a 2022 token.

We could have even go further and check the seeds only if the received account is initialized. Why? Because these accounts grants additional rights. if they are not initialized, it is not a security flaw, we just don’t have these additional rights. If they are initialized, then we need to make sure that the token badge received is linked to the token mint.

4b. CPIs

As previously said, CPI are used in this context to create accounts (system program), and to initialized token accounts (SPL token program). We can’t save Cus here since it is impossible to perform those actions in another way.

BUT. There is another CPI that will be interesting for us. Here it is:

let space = get_account_data_size(
        CpiContext::new(
            token_program.to_account_info(),
            GetAccountDataSize {
                mint: vault_mint.to_account_info(),
            },
        ),
        // Needless to say, the program will never attempt to change the owner of the vault.
        // However, since the ImmutableOwner extension only increases the account size by 4 bytes, the overhead of always including it is negligible.
        // On the other hand, it makes it easier to comply with cases where ImmutableOwner is required, and it adds a layer of safety from a security standpoint.
        // Therefore, we'll include it by default going forward. (Vaults initialized after this change will have the ImmutableOwner extension.)
        if is_token_2022 {
            &[ExtensionType::ImmutableOwner]
        } else {
            &[]
        },
    )?;

What is it used for? When we create an account with system program, we need to define the size of the program (and thus the minimal lamports balance it needs). Why do we need a CPI for that? Because token 2022 can have various extensions, and this impacts the size of the token account, so there is kind of a “helper” CPI to figure out what should be the account size.

BUT again, this is only for token 2022 mints, because legacy spl token accounts have a fixed size of 165 bytes. Again, why do we need this CPI if we can know the account’s size without it? We don’t. The optimization here is quite simple, we need to check if the token is a token 2022:

let mut space = SplTokenAccount::LEN as u64;

    if is_token_2022 {
        space = get_account_data_size(
            CpiContext::new(
                token_program.to_account_info(),
                GetAccountDataSize {
                    mint: vault_mint.to_account_info(),
                },
            ),
            // Needless to say, the program will never attempt to change the owner of the vault.
            // However, since the ImmutableOwner extension only increases the account size by 4 bytes, the overhead of always including it is negligible.
            // On the other hand, it makes it easier to comply with cases where ImmutableOwner is required, and it adds a layer of safety from a security standpoint.
            // Therefore, we'll include it by default going forward. (Vaults initialized after this change will have the ImmutableOwner extension.)
            if is_token_2022 {
                &[ExtensionType::ImmutableOwner]
            } else {
                &[]
            },
        )?;
    }

One more time, if the token is a legacy token, we avoid unnecessary (costly) code.

We could have gone further again, and check activated extensions, because if there are no activated extensions, then we could have hardcoded the space as we did for the legacy program.

4c. Other optimization

We’ve tried something else: The whirlpool PDA is deserialized in the struct because we use pub whirlpools_config: Box<Account<'info, WhirlpoolsConfig>> , let’s try to use pub whirlpools_config: UncheckedAccount<'info>, instead, to avoid some CUs.

1st step: security.

When you use Account<'info, WhirlpoolsConfig> in the struct, anchor will automatically check:

  • the account’s owner program

  • the account’s discriminator

Of course since we now use an unchecked account, we need to check it manually.

2nd step: data.

When anchor deserialize the account, you can access its data though whirlpool_config.xxx. Very useful. We can’t do that with unchecked account, again. So we need to read manually the data. It implies that we need to know the struct of the account. In our instruction, we need to read default_protocol_fee_rate and reward_emissions_super_authority.

We ended up with this code in the handler:

let whirlpools_config_data = ctx.accounts.whirlpools_config.data.borrow();

    // check discriminator
    let expected_disc = WhirlpoolsConfig::DISCRIMINATOR;
    let received_disc: [u8; 8] = whirlpools_config_data[0..8].try_into().unwrap();
    if expected_disc != received_disc {
        return Err(ErrorCode::AccountDiscriminatorMismatch.into());
    }

    // check owner program id
    let received_account_program_id = ctx.accounts.whirlpools_config.owner;
    if received_account_program_id != ctx.program_id {
        return Err(ErrorCode::AccountOwnedByWrongProgram.into());
    }

    let default_protocol_fee_rate: u16 =
        u16::from_le_bytes([whirlpools_config_data[106], whirlpools_config_data[107]]);
    let pubkey_bytes: [u8; 32] = whirlpools_config_data[73..105]
        .try_into()
        .expect("slice with incorrect length");
    let reward_emissions_super_authority: Pubkey = Pubkey::new_from_array(pubkey_bytes);

Did we optimize? Yes. We saved around 300 CUs here. Do we recommend this optimization? no, not usually.

Our take is that it makes the code less readable, harder to maintain (if the PDA struct changes for example). For 300 CUs, it is not worth it.

Note: The expected discriminator was found in the IDL, and the data offsets were manually computed.

Conclusion

We could spend more time looking for more optimizations. But currently, most of the CUs are from remaining seeds constraints, and CPI, that we can’t remove. It follows the 80/20 rule, we’ve optimized roughly 80% of what’s possible (given the constraints, like keeping anchor), with 20% of the time. Probably with a few days of added work, we could save additional 3-4K CUs, but the point here is that it is sometimes not that costly (time) to optimize significantly.

Note: as you probably understood, our optimization stands only if tokens mints are not owned by 2022 program. But our take is that currently a lot of tokens in DEXes are owned by legacy spl token program (USDC, USDT, for example), so it still makes sense.

If you want to check all changes made, you can check this pull request.

Authored by Frederic, Exo Dev

Reply

or to participate

Keep Reading

No posts found