Skip to main content
0x0x-Sol-Settler - June 22, 2026

0x-Sol-Settler

Smart Contract Security Assessment

June 22, 2026

0x

SUMMARY


ABSTRACT

Dedaub was commissioned to perform a security audit of 0x’s Solana sol-settler program.


BACKGROUND

The 0x Sol Settler is a Solana program, built on the Pinocchio framework, that acts as the on-chain settlement layer for an off-chain swap-aggregation service. It exposes a single instruction, SwapAmountIn, which executes a caller-supplied route. This route is an ordered list of composable actions which atomically execute the swap over native SOL, SPL-Token and Token-2022 assets. The off-chain service is responsible for computing the route together with a single slippage bound and the program's role is purely to execute and enforce the route on-chain.


SETTING & CAVEATS

This audit report mainly covers the program code of the at-the-time private 0xProject/sol-settler repository of 0x’s sol settler protocol at commit 6ea0d371e58d13196a83c734e632c26e8d146c05. Fixes were provided by 0x as a git bundle. The remediation review was performed against the resulting fix branch ending at commit 7c57b6ab42d3c59cc044a990a15b3345463fb937.

Audit Start Date: June 08, 2026

Report Submission Date: June 22, 2026

2 auditors worked on the following contracts:

The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.

The 0x Sol Settler composes with many external DEX programs. Although we reviewed the adapter code, SDK construction, instruction encoding, account ordering, and available tests, it was not practical to end-to-end verify every supported DEX path under live-equivalent conditions. Some integrations depend on external programs, liquidity, authorization inputs, or market state that could not be fully reproduced in the audit environment. Accordingly, our review of DEX integrations focused on Settler’s account handling, CPI construction, and post-CPI accounting invariants.


PROTOCOL-LEVEL CONSIDERATIONS

P1

Meteora DLMM adapter cannot execute valid swaps requiring more than three bin arrays

PROTOCOL-LEVEL-CONSIDERATION
info

The Settler Meteora DLMM adapter encodes the number of Meteora remaining accounts in the top two bits of the same u32 field used for amount_in_ppb. As a result, the adapter can represent only 0..=3 remaining accounts.

Both the legacy and V2 Meteora adapters decode the remaining-account count as follows:

src/processor/actions/dex/meteora_dlmm.rs:98-100
let input_data = ctx.read_u32();
let remaining = (input_data >> 30) as usize;
let amount_in = get_amount_in(ctx, input_data, SELL_IDX)?;

The decoded remaining value is then added to the fixed Meteora account count:

src/processor/actions/dex/meteora_dlmm.rs:102-110
invoke_inner(
SWAP_BASE_ACCOUNTS + remaining,
&PROGRAM_ID,
dex_id,
ctx,
SELL_IDX,
BUY_IDX,
&swap_instruction_data(amount_in),
)

For MeteoraDlmmSwapV2, the same pattern is used:

src/processor/actions/dex/meteora_dlmm.rs:128-140
let input_data = ctx.read_u32();
let remaining = (input_data >> 30) as usize;
let amount_in = get_amount_in(ctx, input_data, SELL_IDX)?;

invoke_inner(
SWAP_V2_BASE_ACCOUNTS + remaining,
&PROGRAM_ID,
dex_id,
ctx,
SELL_IDX,
BUY_IDX,
&swap_v2_instruction_data(amount_in),
)

Because only two bits are available, remaining cannot exceed 3. Therefore, Settler can forward at most three Meteora DLMM remaining accounts.

The SDK mirrors this limitation by asserting that no more than three bin arrays are included and packing the bin-array count into the same two high bits:

sdk/src/dex/meteora_dlmm/mod.rs:59-66
assert!(
self.bin_arrays.len() <= 3,
"remaining_accounts_count {} exceeds 2-bit capacity",
self.bin_arrays.len()
);
...
let amount_in_ppb_packed =
self.amount_in_ppb | ((self.bin_arrays.len() as u32) << 30);

However, Meteora DLMM swaps can legitimately require more than three bin-array remaining accounts depending on pool state, liquidity distribution, swap direction, and trade size. The external Meteora program accepts a variable list of bin-array remaining accounts, and live Meteora swaps exist that use more than three bin arrays.

The Swap2 instruction uses 21 accounts. The fixed Swap2 account layout uses 16 accounts, leaving five remaining accounts. Those five remaining accounts are decoded as Meteora-owned BinArray accounts, where the discriminator matches Meteora DLMM’s BinArray account discriminator. The instruction’s remaining_accounts_info field are empty, so these accounts are not Token-2022 transfer-hook accounts.

A valid swap flow can therefore fail through Settler as follows:

  • The route builder selects a Meteora DLMM path.
  • The selected swap requires four or more bin arrays to access the required liquidity.
  • Settler can encode and forward at most three Meteora remaining accounts.
  • The Meteora CPI receives an incomplete bin-array account list.
  • The Meteora swap fails.
  • The entire Settler transaction reverts atomically.

As a result, valid Meteora DLMM swaps that are executable directly against Meteora may be impossible to execute through Settler.


0x Comment: The three-bin-array limit is intentional and should be documented as a business decision. The off-chain router handles which bins to route through and how many are used. While the adapter could pass more bin arrays, the current limit is accepted and purposeful. Support for passing more accounts may be added later if required. Similar behavior exists in other tick- or bin-based DEX integrations.

Dedaub Comment: Given this clarification, we treated this behavior as an intentional route-support constraint. We did not report other instances of similar bounded remaining-account handling in tick- or bin-based DEX adapters.

P2

Route-Level Slippage Can Be Captured By Intermediate Venues

PROTOCOL-LEVEL-CONSIDERATION
info

Settler enforces slippage once at the end of the full route. After all actions have executed, SwapAmountIn re-reads the top-level receiving account and checks that its balance increased by at least min_amount_out:

src/processor/swap_amount_in.rs::process_swap_amount_in():125-131
let buy_token_diff = final_receive_account
.balance()
.checked_sub(initial_receive_amount)
.ok_or(SolSettlerError::ArithmeticOverflow)?;

if buy_token_diff < min_amount_out {
return Err(SolSettlerError::SlippageToleranceExceeded.into());
}

Individual DEX legs do not enforce per-hop expected output amounts. Instead, the DEX adapters generally pass zero as the DEX-level minimum output or threshold and rely on Settler’s final route-level check.

The shared DEX helper records the actual balance movement produced by each CPI. It debits the observed sell-account decrease and credits the observed buy-account increase:

src/processor/actions/dex/mod.rs::invoke_inner():144-148
let debit = sell_bal_before
.checked_sub(sell_post.balance())
.ok_or(SolSettlerError::ArithmeticOverflow)?;

let credit = buy_post
.balance()
.checked_sub(buy_bal_before)
.ok_or(SolSettlerError::ArithmeticOverflow)?;

ctx.intra_program_balances.debit(*sell_acct.address(), debit);
ctx.intra_program_balances.credit(*buy_acct.address(), credit);

This observed output is then fed into later route actions through TokenBalanceCache.

As a result, any slack between the off-chain expected route output and the final min_amount_out is not assigned to a specific hop. A DEX leg can execute at a worse price than quoted, and the transaction will still succeed as long as the final output remains above the route-level minimum. The available slack can be described as: off-chain expected output - min_amount_out. This slack exists to tolerate price movement between quote construction and on-chain settlement. However, the program does not enforce per-hop price bounds, any intermediate venue can consume that slack.

As a result, route-level slippage can be captured by any intermediate venue rather than being preserved for the user, reflected as better final output, or collected through a later TradeSurplus action. This is especially significant for intermediate venues that have access to the instructions sysvar, as this allows introspection on the transaction which could facilitate targeted slippage capture.

Still, this behavior is consistent with a route-level settlement design, but it means the off-chain router should treat DEX venues as economically adversarial and monitor realized execution quality, not only transaction success.



VULNERABILITIES & FUNCTIONAL ISSUES

This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:

CATEGORY
DESCRIPTION
CRITICAL
Can be profitably exploited by any knowledgeable third-party attacker to drain a portion of the system’s or users’ funds OR the contract does not function as intended and severe loss of funds may result.
HIGH
Third-party attackers or faulty functionality may block the system or cause the system or users to lose funds. Important system invariants can be violated.
MEDIUM
Examples:
  • User or system funds can be lost when third-party systems misbehave
  • DoS, under specific conditions
  • Part of the functionality becomes unusable due to a programming error
LOW
Examples:
  • Breaking important system invariants but without apparent consequences
  • Buggy functionality for trusted users where a workaround exists
  • Security issues which may manifest when the system evolves

Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.


CRITICAL SEVERITY

[No critical severity issues]


HIGH SEVERITY

[No high severity issues]


MEDIUM SEVERITY

M1

TransferSolToWsol can create internal balance for accounts that are not WSOL accounts

MEDIUM
resolved

TransferSolToWsol is intended to move native SOL into an existing WSOL token account, invoke SyncNative, and then credit the WSOL account in TokenBalanceCache so later route actions can spend the newly wrapped WSOL.

However, the action does not validate the assumptions required for that cache credit.

The implementation reads both the token program and destination account from the route-supplied account list:

src/processor/actions/misc/transfer_sol_to_wsol.rs::TransferSolToWsol:30-43
let token_program_info = &ctx.accounts[ctx.accounts_offset + 1];
let from_account_info = &ctx.accounts[ctx.accounts_offset + 2];
let wsol_account_info = &ctx.accounts[ctx.accounts_offset + 3];

let intra_program_balance = ctx.intra_program_balances.get(from_account_info.address())?;
let amount = amount_from_ppb(*intra_program_balance, amount_in_ppb)?;

pinocchio_system::instructions::Transfer { from: from_account_info, to: wsol_account_info, lamports: amount }
.invoke()?;

SyncNative { native_token: wsol_account_info, token_program: token_program_info.address() }.invoke()?;

ctx.intra_program_balances.debit(*from_account_info.address(), amount);
ctx.intra_program_balances.credit(*wsol_account_info.address(), amount);

The action therefore:

  • accepts a caller-supplied token_program_info.
  • uses token_program_info.address() as the SyncNative CPI target.
  • accepts a caller-supplied wsol_account_info.
  • transfers lamports to wsol_account_info.
  • credits wsol_account_info in TokenBalanceCache by the requested transfer amount.

It does not verify that:

  • token_program_info is the legacy SPL Token program.
  • wsol_account_info is owned by the legacy SPL Token program.
  • wsol_account_info is a token account for the WSOL mint.
  • The WSOL token balance actually increased.
  • The credited amount equals the observed WSOL balance delta.

As a result, the action can create route-local cache balance for an arbitrary account address even when no WSOL token balance was created.

This violates the route-local accounting model. Settler’s TokenBalanceCache is intended to track balances made available during the current instruction. It is initialized with only the top-level sell account and amount_in, and actions are expected to spend only cached route-local balances rather than arbitrary pre-existing token-account balances.

The cache itself is keyed only by account address and exposes credit() as an unconditional increase or insert for that address:

src/token_cache.rs::TokenBalanceCache:20-22
pub fn credit(&mut self, key: Address, value: u64) {
self.0.entry(key).and_modify(|v| *v = v.checked_add(value).unwrap()).or_insert(value);
}

Once TransferSolToWsol credits an arbitrary non-WSOL address, later actions can treat that address as having spendable in-route balance.

DEX actions compute their input amount from TokenBalanceCache, not from a proof that the selected sell account was actually credited by a previous token transfer. The shared DEX helper reads the selected sell account’s cached balance and derives the CPI input amount from that value:

src/processor/actions/dex/mod.rs::get_amount_in():70-73
let sell_balance = {
let sell_account = &ctx.accounts[ctx.accounts_offset + sell_idx];
*ctx.intra_program_balances.get(sell_account.address())?
};

amount_from_ppb(sell_balance, amount_in_ppb)

After the DEX CPI, the helper debits the observed decrease in the selected sell account and credits the observed increase in the selected buy account:

src/processor/actions/dex/mod.rs::invoke_inner():144-148
let debit = sell_bal_before.checked_sub(sell_post.balance()).ok_or(SolSettlerError::ArithmeticOverflow)?;
let credit = buy_post.balance().checked_sub(buy_bal_before).ok_or(SolSettlerError::ArithmeticOverflow)?;

ctx.intra_program_balances.debit(*sell_acct.address(), debit);
ctx.intra_program_balances.credit(*buy_acct.address(), credit);

This is safe when cache credits correspond to real route-created token balance. It becomes unsafe when TransferSolToWsol has manufactured cache balance for a normal token account. In that case, a later DEX action can select the poisoned account as the sell account and spend the account’s real pre-existing token balance while Settler treats the spend as consuming route-created balance.

The generic Transfer action can also be used as a sink. It computes the transfer amount from the cached balance of from_account_info, then performs a system transfer or TransferChecked, and finally debits the observed balance decrease. Therefore, a fake cache entry can allow a later transfer action to spend pre-existing tokens from the credited address if the required token authority is present.

Since the route is not malicious in the expected operating model and is normally produced by the official route builder, this decreases the immediate impact if the official router is fully trusted and never emits malformed action sequences. Under that trust model, this is primarily an on-chain accounting invariant violation and a route-builder hardening issue.

If route construction is faulty, compromised, exposed to third-party integrators, or otherwise attacker-controlled, the impact is significantly higher. A malicious route can pass a normal user token account, such as the user’s USDC account, as wsol_account_info, and pass an executable no-op program as token_program_info. The system transfer sends lamports to the USDC token account, but no USDC balance is created. The fake SyncNative CPI returns success, and Settler still credits the USDC account address in TokenBalanceCache.

A later Transfer or DEX action can then spend from that USDC account because the cache now treats it as having route-local balance. If the user’s token authority is present or signing, the route can spend pre-existing tokens that were never supposed to be part of the route input.

The final top-level checks do not prevent this. At the end of SwapAmountIn, Settler re-reads only the top-level sell account and receiving account. It verifies that the sell account did not decrease by more than amount_in and that the receiving account increased by at least min_amount_out. It does not check unrelated action accounts that may have been incorrectly credited and spent.

This is particularly dangerous because the credited integer changes denomination. The amount transferred by TransferSolToWsol is denominated in lamports, but if the destination is a non-WSOL token account, the same integer becomes cache credit in that token’s base units. For example 1_000_000_000 lamports transferred to a USDC token account becomes 1_000_000_000 units of internal USDC cache credit

For a 6-decimal token, that corresponds to 1,000 tokens of apparent route-local balance, assuming the token account already contains enough pre-existing balance to be spent.

Example scenario:

  • The user signs a transaction where the top-level sell account is native SOL.
  • The route includes TransferSolToWsol.
  • The route passes the user’s existing USDC token account as wsol_account_info.
  • The route passes an executable no-op program as token_program_info, so the SyncNative CPI returns success without syncing WSOL.
  • Settler transfers SOL lamports into the USDC token account.
  • No USDC token balance is created.
  • Settler still credits the USDC token account address in TokenBalanceCache.
    1. A later Transfer or DEX action uses the USDC account as the source or sell account.
  • The later action computes its input amount from the fake cache balance.
  • If the user’s token authority is present or signing, the route spends the user’s pre-existing USDC.
  • Settler debits the observed USDC decrease from the fake cache entry and continues execution.

The user’s USDC balance existed before the route and was never supposed to become part of the route’s available input. The forged cache credit causes Settler to treat it as balance created by the SOL-to-WSOL action.

The action assumes that:

  • The supplied token program is the correct token program.
  • The supplied destination account is a WSOL account.
  • SyncNative actually made the transferred lamports visible as WSOL.
  • The transferred lamport amount is safe to credit as token balance for the destination account.

None of those assumptions are enforced on-chain.

M2

Pre-Funded WSOL PDA Can Be Initialized Without The Payer Signing

MEDIUM
resolved

CreateWsolPda is intended to initialize a reusable WSOL token account PDA for a payer. The action derives signer seeds from the static WSOL PDA seed, the supplied payer_info address, and the supplied bump:

src/processor/actions/misc/create_wsol_pda.rs::invoke():35-37
let bump_seed = [bump];
let seeds = [
Seed::from(WSOL_PDA_SEED),
Seed::from(payer_info.address().as_ref()),
Seed::from(≎_seed),
];
let signer = Signer::from(&seeds

The action computes the rent-exempt balance required for an SPL Token account and only transfers lamports from payer_info if the supplied PDA is underfunded:

src/processor/actions/misc/create_wsol_pda.rs::invoke():33,40-43
let required = rent.minimum_balance_unchecked(pinocchio_token::state::Account::LEN);

let top_up = required.saturating_sub(pda_info.lamports());
if top_up > 0 {
Transfer {
from: payer_info,
to: pda_info,
lamports: top_up,
}
.invoke()?;
}

This creates an authorization gap. payer_info is not explicitly required to be a signer. In normal use, the rent top-up transfer indirectly requires payer_info to sign, because the System Program transfer moves lamports from payer_info. However, if the PDA has already been funded with enough lamports, then top_up == 0.

and the transfer from payer_info is skipped. In that case, no operation requires payer_info to sign before the account is allocated, assigned, and initialized.

The program then allocates and assigns the PDA using Settler PDA signer seeds:

src/processor/actions/misc/create_wsol_pda.rs::invoke():45-48
Allocate {
account: pda_info,
space: pinocchio_token::state::Account::LEN as u64,
}
.invoke_signed(core::slice::from_ref(&signer))?;

Assign {
account: pda_info,
owner: token_program_info.address(),
}
.invoke_signed(&[signer])?;

Finally, it initializes the account as a WSOL token account owned by payer_info.address():

src/processor/actions/misc/create_wsol_pda.rs::invoke():50
InitializeAccount3 {
account: pda_info,
mint: wsol_mint_info,
owner: payer_info.address(),
}
.invoke()?;

\InitializeAccount3 does not require the token-account owner to sign. Therefore, a third party can initialize another user’s pre-funded Settler WSOL PDA without that user authorizing the action.

Attack Path:

  • The attacker chooses a victim wallet.
  • The attacker derives the victim’s Settler WSOL PDA:
PDA = find_program_address(["wsol", victim])
  • The attacker funds that PDA with at least the rent-exempt amount required for an SPL Token account.
  • The attacker calls CreateWsolPda with:
payer_info = victim \payer_info.is_signer = false \pda_info = victim-derived WSOL PDA
  • Since the PDA is already rent-funded, top_up == 0, and the transfer from payer_info is skipped.
  • Settler allocates, assigns, and initializes the PDA as a WSOL token account owned by the victim.
  • Later, the victim attempts a normal SOL route that includes CreateWsolPda.
  • The route fails because the PDA is already initialized and can no longer be allocated and initialized as expected.

A third party can pre-initialize a victim’s deterministic Settler WSOL PDA without the victim’s signature, which can block future SOL routes that rely on CreateWsolPda creating the PDA as part of the route.

M3

Untracked WSOL Dust Can Cause SOL Unwrap Routes Using Reusable Accounts To Revert

MEDIUM
resolved

Settler tracks route-local balances in TokenBalanceCache. This cache is intended to represent only balances made available inside the current settlement route, not the full balance of every token account included in the transaction.

UnwrapSol is intentionally strict. Before closing a WSOL account, it reads the account’s current effective WSOL balance and requires it to exactly match the amount tracked in TokenBalanceCache:

src/processor/actions/misc/unwrap_sol.rs::invoke():21-32
let wsol_amount = {
let account = Account::from_account_view(wsol_account_info)?;
if account.mint().ne(&WSOL_ADDRESS) {
return Err(SolSettlerError::InvalidUnwrap.into());
}
account.balance()
};

let tracked_wsol_amount = *ctx.intra_program_balances.get(wsol_account_info.address())?;
if tracked_wsol_amount != wsol_amount {
return Err(SolSettlerError::InvalidUnwrap.into());
}

If the equality check passes, the account is closed, removed from the cache, and the destination account is credited only by the tracked WSOL amount:

src/processor/actions/misc/unwrap_sol.rs::invoke():34-37
CloseAccount::new(wsol_account_info, destination_account_info, destination_account_info).invoke()?;

ctx.intra_program_balances.remove(wsol_account_info.address());
ctx.intra_program_balances.credit(*destination_account_info.address(), wsol_amount);

This check correctly prevents Settler from closing a WSOL account that contains unrelated pre-existing balance. However, it also makes SOL unwrap routes sensitive to unsolicited WSOL dust or excess prefunding when they use predictable or reusable WSOL accounts.

For native token accounts, Settler’s token interface computes the effective balance as the account lamports minus the native reserve:

\src/token_interface.rs::balance():57-62
if account.is_native() {
account_view.lamports().saturating_sub(account.native_amount_unchecked())
} else {
account.amount()
}

Therefore, unsolicited lamports sent to an initialized WSOL account can increase the effective WSOL balance observed by Settler. The cache does not automatically include that externally supplied amount.

This can also happen with deterministic Settler WSOL PDAs. CreateWsolPda only tops up the PDA if its lamport balance is below the rent-exempt minimum:

src/processor/actions/misc/create_wsol_pda.rs::invoke():40-43
let top_up = required.saturating_sub(pda_info.lamports());
if top_up > 0 {
Transfer { from: payer_info, to: pda_info, lamports: top_up }.invoke()?;
}

After allocation, assignment, and initialization, the PDA is inserted into TokenBalanceCache with zero tracked WSOL:

src/processor/actions/misc/create_wsol_pda.rs::invoke():52
ctx.intra_program_balances.insert(*pda_info.address(), 0);

If the PDA was prefunded with more than the rent-exempt reserve, the excess lamports become effective WSOL after initialization, but Settler still tracks zero route-created WSOL for the account.

Later, TransferSolToWsol can transfer SOL into the same WSOL account and credit only the route-transferred amount:

src/processor/actions/misc/transfer_sol_to_wsol.rs::invoke():37-43
pinocchio_system::instructions::Transfer {
from: from_account_info,
to: wsol_account_info,
lamports: amount,
}
.invoke()?;

SyncNative {
native_token: wsol_account_info,
token_program: token_program_info.address(),
}.invoke()?;

ctx.intra_program_balances.debit(*from_account_info.address(), amount);
ctx.intra_program_balances.credit(*wsol_account_info.address(), amount););

The resulting state is:

  • actual WSOL balance = external dust or excess prefunding + route-created WSOL
  • tracked WSOL balance = route-created WSOL

When UnwrapSol runs, the exact equality check fails and the whole settlement transaction reverts. An attacker can cause targeted SOL unwrap routes to fail when those routes use a predictable or reusable WSOL account.



LOW SEVERITY

L1

PancakeSwap Token-2022 Support Is Documented But Not Implemented In The Adapter Account Shape

LOW
resolved

The DEX adapter README states that PancakeSwap supports Token-2022 swaps through swap_v2. However, the implemented PancakeSwap adapter and SDK use a legacy SPL-token-style swap shape. They do not include the accounts normally needed for a Token-2022-capable swap variant, such as separate token program accounts, mint accounts, or a memo program.

In the README.md, PancakeSwap is documented as supporting Token-2022:

src/processor/actions/dex/README.md:166-174
## PANCAKESWAP

### SPL to SPL

- `PancakeSwap` — `swap`

### Token-2022 Involved

- `PancakeSwap` — `swap_v2`

The on-chain adapter does not reflect that shape. It defines a single fixed layout with one token program account:

src/processor/actions/dex/pancakeswap.rs:14-27
/// Fixed accounts:
/// * user(0)
/// * amm_config(1)
/// * pool(2)
/// * user_input(3)
/// * user_output(4)
/// * pool_input_vault(5)
/// * pool_output_vault(6)
/// * observation_key(7)
/// * spl_token(8)
/// * tick_array_0(9)
///
/// Followed by 0–3 optional remaining accounts.
const BASE_ACCOUNTS: usize = 10;

It then invokes the external PancakeSwap program using that legacy account count plus the packed remaining-account count:

src/processor/actions/dex/pancakeswap.rs:invoke():61-73
let input_data = ctx.read_u32();
let remaining = (input_data >> 30) as usize;
let amount_in = get_amount_in(ctx, input_data, SELL_IDX)?;

invoke_inner(
BASE_ACCOUNTS + remaining,
&PROGRAM_ID,
dex_id,
ctx,
SELL_IDX,
BUY_IDX,
&instruction_data(amount_in),

The instruction payload also uses the ordinary swap discriminator and legacy 41-byte shape:

src/processor/actions/dex/pancakeswap.rs:33-48
const SWAP_DISCRIMINATOR: [u8; 8] =
[248, 198, 158, 145, 225, 117, 135, 200];

fn instruction_data(sell_amount: u64) -> [u8; 41] {
let mut data = [0u8; 41];
data[..8].copy_from_slice(&SWAP_DISCRIMINATOR);
data[8..16].copy_from_slice(&sell_amount.to_le_bytes());
data[40] = 1;
data
}

The SDK mirrors this legacy shape. In sdk/src/dex/pancakeswap.rs, the SDK always supplies the token interface program at the single token-program position and does not include input/output mint accounts:

sdk/src/dex/pancakeswap.rs:46
AccountMeta::new_readonly(spl_token_interface::ID, false),

The account list contains no dedicated fields for:

  • token_program_input
  • token_program_output
  • mint_input
  • mint_output
  • memo_program

This is inconsistent with the README claim that PancakeSwap Token-2022 swaps are supported through swap_v2.

L2

Unchecked Token Account Views Are Held Across CPIs, Creating Unsound Borrowing And Potential Stale Account Reads

LOW
resolved

The program defines a unified token-account wrapper that stores references directly into account data:

src/token_interface.rs:22-25
pub enum Account<'info> {
Token((&'info AccountView, &'info T22Account)),
Native(&'info AccountView),
}

Token accounts are parsed using unchecked APIs:

src/token_interface.rs:28-45
pub fn from_account_view(account_view: &'info AccountView) -> Result<Self, ProgramError> {
if account_view.owned_by(&pinocchio_system::ID) {
Ok(Account::Native(account_view))
} else if account_view.owned_by(&pinocchio_token_2022::ID) {
// SAFETY: The account has been validated to be owned by the Token-2022 program.
let account_info = unsafe { T22Account::from_account_view_unchecked(account_view)? };
Ok(Account::Token((account_view, account_info)))
} else if account_view.owned_by(&pinocchio_token::ID) {
if account_view.data_len() != pinocchio_token::state::Account::LEN {
return Err(ProgramError::InvalidAccountData);
}
// SAFETY: Legacy Token and Token-2022 token account structs share the same base layout.
let account_info = unsafe { T22Account::from_bytes_unchecked(account_view.borrow_unchecked()) };
Ok(Account::Token((account_view, account_info)))
} else {
Err(ProgramError::InvalidAccountOwner)
}
}

This is not limited to Token-2022 accounts. The unsafe abstraction is used for both pinocchio_token_2022::ID and legacy SPL Token accounts pinocchio_token::ID.

So the issue applies to legacy SPL-token routes as well. Token-2022 support may have introduced or expanded this unified abstraction, but the current risk surface includes both SPL Token and Token-2022 paths.

The issue is that from_account_view_unchecked / borrow_unchecked create immutable references into account data without registering a checked Pinocchio borrow. Those references can then remain live while Settler performs CPIs that may mutate the same account data.

A clear example is the Transfer action:

src/processor/actions/misc/transfer.rs::invoke():30-32
let from_account = Account::from_account_view(from_account_info)?;
let token_balance_before = from_account.balance();
let amount = amount_from_ppb(*intra_program_balance, amount_in_ppb)?;

For token transfers, Settler then invokes the token program:

src/processor/actions/misc/transfer.rs::invoke():43-52
TransferChecked {
from: from_account_info,
mint: mint_account_info,
to: to_account_info,
authority: authority_account_info,
amount,
decimals: mint_decimals,
token_program: program_info.address(),
}
.invoke()?;

After the CPI, Settler reads the same from_account object again:

src/processor/actions/misc/transfer.rs::invoke():58-61
let token_balance_after = from_account.balance();
let token_debit =
token_balance_before.checked_sub(token_balance_after).ok_or(SolSettlerError::ArithmeticOverflow)?;
ctx.intra_program_balances.debit(*from_account_info.address(), token_debit);

The problem is that from_account contains an immutable reference into the source token account data. The TransferChecked CPI is expected to mutate that same source token account. Formally, that means account data referenced immutably by the caller can be mutated by the callee while the immutable reference is still live.

The same pattern exists in TradeSurplus , the token branch parses from_account, performs a token CPI, then reads from_account again:

src/processor/actions/misc/trade_surplus.rs::invoke_trade_surplus():104-120
let from_account = Account::from_account_view(from_account_info)?;
let token_balance_before = from_account.balance();
let mint_decimals = Mint::decimals_from(mint_account_info)?;
TransferChecked {
from: from_account_info,
mint: mint_account_info,
to: to_account_info,
authority: authority_account_info,
amount,
decimals: mint_decimals,
token_program: program_info.address(),
}
.invoke()?;
let token_balance_after = from_account.balance();
let token_debit =
token_balance_before.checked_sub(token_balance_after).ok_or(SolSettlerError::ArithmeticOverflow)?;
ctx.intra_program_balances.debit(*from_account_info.address(), token_debit);

DEX swaps have a related pattern, where the program parses the sell and buy token accounts before invoking an external DEX:

src/processor/actions/dex/mod.rs::invoke_inner():94-95
let sell_acct = token_interface::Account::from_account_view(&accounts[sell_idx])?;
let buy_acct = token_interface::Account::from_account_view(&accounts[buy_idx])?;

It then reads fields from those account references:

src/processor/actions/dex/mod.rs::invoke_inner():103-118
let sell_mint = sell_acct.mint();
let buy_mint = buy_acct.mint();
let sell_bal_before = sell_acct.balance();
let buy_bal_before = buy_acct.balance();
let sell_auth_before = (
sell_acct.owner().copied(),
sell_acct.close_authority().copied(),
sell_acct.delegate().copied(),
sell_acct.delegated_amount(),
);
let buy_auth_before = (
buy_acct.owner().copied(),
buy_acct.close_authority().copied(),
buy_acct.delegate().copied(),
buy_acct.delegated_amount(),
);

Then Settler invokes the external DEX:

src/processor/actions/dex/mod.rs::invoke_inner():120-121
ctx.mint_tracker.check_and_register(sell_mint, buy_mint)?;
invoke_with_slice(&ix, accounts)?;

After the CPI, it reparses the post-state:

src/processor/actions/dex/mod.rs::invoke_inner():123-124
let sell_post = token_interface::Account::from_account_view(&accounts[sell_idx])?;
let buy_post = token_interface::Account::from_account_view(&accounts[buy_idx])?;

But the pre-CPI sell_acct / buy_acct objects, and especially sell_mint / buy_mint references, are still used after the CPI:

src/processor/actions/dex/mod.rs::invoke_inner():147-149
ctx.intra_program_balances.debit(*sell_acct.address(), debit);
ctx.intra_program_balances.credit(*buy_acct.address(), credit);
emit_swap_leg_event(program_id, dex_id, sell_mint, debit, buy_mint, credit);

Even if the token mint field is not expected to change in a valid token account, the broader pattern still keeps references into account data live across an external CPI that can mutate the same accounts.

This violates the safety model expected by Pinocchio/Solana account borrows. The dependency documentation supports this interpretation: unchecked borrows do not update the account borrow state, and CPI helpers document that if account data borrowed by the caller is written by the callee, Rust aliasing rules can be violated and the behavior becomes undefined.

The practical consequence is currently bounded. In normal Solana execution this is single-threaded, and based on current testing we have not seen the compiler reuse stale token-account values after CPI. However, the code is not formally sound. The compiler is allowed to assume immutable references are not mutated for their lifetime.

L3

CreateWsolPda Does Not Verify The Supplied PDA Account And Can Initialize An Arbitrary Signer System Account

LOW
resolved

CreateWsolPda is intended to initialize the Settler WSOL PDA derived from ["wsol", payer_info.address()]. The action receives payer_info and pda_info from the caller-supplied account list:

src/processor/actions/misc/create_wsol_pda.rs::invoke():24-25
let payer_info = &ctx.accounts[ctx.accounts_offset + 2];
let pda_info = &ctx.accounts[ctx.accounts_offset + 3];

It then constructs PDA signer seeds using payer_info.address() and the supplied bump:

src/processor/actions/misc/create_wsol_pda.rs::invoke():35-37
let payer_info = &ctx.accounts[ctx.accounts_offset + 2];
let pda_info = &ctx.accounts[ctx.accounts_offset + 3];

However, the action never verifies that the supplied pda_info address actually equals the PDA derived from those seeds.

The program then calls System Program Allocate and Assign on the caller-supplied pda_info:

src/processor/actions/misc/create_wsol_pda.rs::invoke():45-48
Allocate {
account: pda_info,
space: pinocchio_token::state::Account::LEN as u64,
}
.invoke_signed(core::slice::from_ref(&signer))?;

Assign {
account: pda_info,
owner: token_program_info.address(),
}
.invoke_signed(&[signer])?;

The invoke_signed seeds do not by themselves prove that pda_info is the intended PDA. They only add PDA signer privileges to the CPI. If the caller supplies a normal writable system account that is already a signer in the outer transaction, the System Program can authorize Allocate and Assign using that normal signature.

Finally, the action initializes the same caller-supplied account as a WSOL token account owned by payer_info.address():

src/processor/actions/misc/create_wsol_pda.rs::invoke():50
InitializeAccount3 {
account: pda_info,
mint: wsol_mint_info,
owner: payer_info.address(),
}
.invoke()?;

As a result, if the route was malicious or malformed, which isn’t the case for the intended model, it could cause an arbitrary signer system account to be allocated, assigned to the token program, and initialized as a WSOL token account, even though it is not the expected Settler PDA.

L4

Native Sol minimum output can be satisfied by recycled WSol rent

LOW
resolved

The program enforces the top-level min_amount_out check by comparing the final balance of the receiving account against its initial balance.

In process_swap_amount_in, the program records the initial receive balance:

src/processor/swap_amount_in.rs:37
let initial_receive_amount = receive_account.balance();

After all actions execute, it re-reads the receiving account and checks that the balance increase is at least min_amount_out:

src/processor/swap_amount_in.rs:125-131
let buy_token_diff = final_receive_account
.balance()
.checked_sub(initial_receive_amount)
.ok_or(SolSettlerError::ArithmeticOverflow)?;

if buy_token_diff < min_amount_out {
return Err(SolSettlerError::SlippageToleranceExceeded.into());
}

For native SOL system accounts, Account::balance() returns the account's raw lamport balance:

src/token_interface.rs:63
Account::Native(account_view) => account_view.lamports(),

This means the final native SOL output check counts any lamports that arrive in the receiving account during the route, including rent reserve lamports returned by closing an existing WSOL token account.

UnwrapSol closes a WSOL token account into an arbitrary destination account:

src/processor/actions/misc/unwrap_sol.rs:34
CloseAccount::new(wsol_account_info, destination_account_info, destination_account_info).invoke()?;

Before closing, it only requires that the WSOL token amount equals the route-tracked WSOL amount:

src/processor/actions/misc/unwrap_sol.rs:29-32
let tracked_wsol_amount = *ctx.intra_program_balances.get(wsol_account_info.address())?;
if tracked_wsol_amount != wsol_amount {
return Err(SolSettlerError::InvalidUnwrap.into());
}

This checks the WSOL token amount, but it does not distinguish between new SOL output produced by the settlement route and pre-existing rent reserve returned from closing a WSOL account.

As a result, a route can satisfy a native SOL min_amount_out using recycled rent from a pre-existing empty WSOL account, even if the route did not produce that SOL as swap output.

L5

UnwrapSol can create cross-asset cache credit for signer token accounts

LOW
resolved

UnwrapSol closes a WSOL account and credits the destination account in TokenBalanceCache by the WSOL token amount:

src/processor/actions/misc/unwrap_sol.rs:34-37
CloseAccount::new(wsol_account_info, destination_account_info, destination_account_info).invoke()?;

ctx.intra_program_balances.remove(wsol_account_info.address());
ctx.intra_program_balances.credit(*destination_account_info.address(), wsol_amount);

The action does not require the destination to be a native SOL system account.

This is important because TokenBalanceCache is keyed only by account address. It does not bind the cached balance to an asset type, mint, or account kind. If UnwrapSol credits a destination address that is actually a non-native token account, later actions can treat that cache entry as a spendable balance for that token account.

The Transfer action demonstrates the issue. It reads the cached balance for the source account address:

src/processor/actions/misc/transfer.rs:29-32
let intra_program_balance = ctx.intra_program_balances.get(from_account_info.address())?;
...
let amount = amount_from_ppb(*intra_program_balance, amount_in_ppb)?;

If the caller chooses a token transfer path, Transfer then performs a token TransferChecked from that same account:

src/processor/actions/misc/transfer.rs:43-52
TransferChecked {
from: from_account_info,
mint: mint_account_info,
to: to_account_info,
authority: authority_account_info,
amount,
decimals: mint_decimals,
token_program: program_info.address(),
}
.invoke()?;

This means a cache credit created from unwrapping WSOL can become spendable as an unrelated token balance if the destination account is also a token account and the route has the required signing authority.



OTHER / ADVISORY ISSUES

This section details issues that are not thought to directly affect the functionality of the project, but we recommend considering them.

A1

CreateWsolPda rent top-ups are not accounted for in the route balance cache

ADVISORY
resolved

CreateWsolPda may transfer lamports from payer_info to the WSOL PDA to make the account rent-exempt. However, this rent top-up is not reflected in TokenBalanceCache.

src/processor/actions/misc/create_wsol_pda.rs:33
let required = rent.minimum_balance_unchecked(pinocchio_token::state::Account::LEN);

let top_up = required.saturating_sub(pda_info.lamports());
if top_up > 0 {
Transfer {
from: payer_info,
to: pda_info,
lamports: top_up,
}
.invoke()?;
}

After the account is initialized, the route cache inserts the PDA with a zero token balance:

src/processor/actions/misc/create_wsol_pda.rs:50
InitializeAccount3 {
account: pda_info,
mint: wsol_mint_info,
owner: payer_info.address(),
}
.invoke()?;

ctx.intra_program_balances.insert(*pda_info.address(), 0);

There is no corresponding debit for the rent lamports transferred from payer_info.

If payer_info is the top-level native SOL sell account, the final max-input check should catch the lamport decrease:

src/processor/swap_amount_in.rs:118
let sell_token_diff =
initial_sell_amount.checked_sub(final_sell_account.balance())?;

if sell_token_diff > amount_in {
return Err(SolSettlerError::MaxAmountInExceeded.into());
}

However, if payer_info is some other signer system account that is not the top-level sell account, the rent top-up is outside the route’s internal accounting and outside the final amount_in protection. This means amount_in does not necessarily represent all lamports a signer may spend during the route.

A2

SwapAmountIn Accepts Trailing Action Data And Accounts After The Declared Action Count

ADVISORY
info

SwapAmountIn parses a declared number_of_actions from the instruction data and executes exactly that many actions.

The processor reads the instruction header:

src/processor/swap_amount_in.rs::process_swap_amount_in():28-30
let mut amount_in = read_u64(instruction_data, &mut offset);
let min_amount_out = read_u64(instruction_data, &mut offset);
let number_of_actions = read_u8(instruction_data, &mut offset);

It then initializes the action parsing context:

src/processor/swap_amount_in.rs::process_swap_amount_in():59-66
let mut ctx = SwapContext {
accounts,
accounts_offset: 2,
data: instruction_data,
data_offset: offset,
intra_program_balances: &mut intra_program_balances,
mint_tracker: &mut mint_tracker,
};

The processor executes exactly number_of_actions actions:

src/processor/swap_amount_in.rs::process_swap_amount_in():68-107
for _ in 0..number_of_actions {
let action = ctx.data[ctx.data_offset];
ctx.data_offset += 1;

match action {
RaydiumAmmSwapV2::DISCRIMINATOR => RaydiumAmmSwapV2::invoke(&mut ctx)?,
OrcaWhirlpoolsSwap::DISCRIMINATOR => OrcaWhirlpoolsSwap::invoke(&mut ctx)?,
// ...
OrcaWhirlpoolsSwapV2::DISCRIMINATOR => OrcaWhirlpoolsSwapV2::invoke(&mut ctx)?,
_ => return Err(SolSettlerError::UnsupportedAction.into()),
}
}

After that loop completes, the program proceeds directly to the final sell-account and receive-account checks:

src/processor/swap_amount_in.rs::process_swap_amount_in():109-110
let final_sell_account = Account::from_account_view(&accounts[SELL_ACCOUNT_INDEX])?;
let final_receive_account = Account::from_account_view(&accounts[RECEIVING_ACCOUNT_INDEX])?;

The processor does not verify that all serialized instruction bytes were consumed or that all supplied accounts were consumed by the executed action list. As a result, a hand-built instruction can append trailing serialized bytes or extra accounts after the declared action sequence. These trailing bytes and accounts are ignored by the on-chain processor if the declared actions and final balance checks succeed.

A3

TradeSurplus Can Become A Silent No-Op When The Source And Recipient Are The Same Account

ADVISORY
resolved

TradeSurplus is intended to transfer realized surplus from a source account to a configured recipient. The action first computes the surplus amount from the source account’s route-local cached balance:

src/processor/actions/misc/trade_surplus.rs::invoke_trade_surplus():69-74
let intra_program_balance = *ctx.intra_program_balances.get(from_account_info.address())?;
let amount = if intra_program_balance > expected_amount {
compute_trade_surplus_amount(
intra_program_balance,
expected_amount,
trade_surplus_cap_ppm,
)?
} else {
0
};

The action then performs either a System Program transfer or a Token Program TransferChecked, and debits TokenBalanceCache by the observed balance decrease of the source account.

For the SOL branch:

src/processor/actions/misc/trade_surplus.rs::invoke_trade_surplus():84-91
let from_account = Account::from_account_view(from_account_info)?;
let token_balance_before = from_account.balance();

SystemTransfer {
from: from_account_info,
to: to_account_info,
lamports: amount,
}
.invoke()?;

let token_balance_after = from_account.balance();
let token_debit =
token_balance_before
.checked_sub(token_balance_after)
.ok_or(SolSettlerError::ArithmeticOverflow)?;

ctx.intra_program_balances.debit(*from_account_info.address(), token_debit);

The token branch uses the same accounting pattern after TransferChecked. If the configured recipient account is the same as the source account, the transfer is a self-transfer. The source account’s balance does not decrease, so the measured debit is zero:

  • token_balance_before == token_balance_after
  • token_debit == 0

As a result, the TradeSurplus action can complete without moving surplus out of the source account and without debiting the source account’s cached balance.

The resulting state is:

  • actual source balance unchanged
  • TokenBalanceCache[source] unchanged
  • configured surplus recipient receives no separate surplus

As a result, the surplus remains in the source account and remains spendable by later route actions.



DISCLAIMER

The audited contracts have been analyzed using automated techniques and extensive human inspection in accordance with state-of-the-art practices as of the date of this report. The audit makes no statements or warranties on the security of the code. On its own, it cannot be considered a sufficient assessment of the correctness of the contract. While we have conducted an analysis to the best of our ability, it is our recommendation for high-value contracts to commission several independent audits, a public bug bounty program, as well as continuous security auditing and monitoring through Dedaub Security Suite.


ABOUT DEDAUB

Dedaub offers significant security expertise combined with cutting-edge program analysis technology to secure some of the most prominent protocols in DeFi. The founders, as well as many of Dedaub's auditors, have a strong academic research background together with a real-world hacker mentality to secure code. Protocol blockchain developers hire us for our foundational analysis tools and deep expertise in program analysis, reverse engineering, DeFi exploits, cryptography and financial mathematics.