Organization
- @multiliquid
Engagement Type
Cantina Reviews
Period
-
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
6 findings
5 fixed
1 acknowledged
Informational
13 findings
12 fixed
1 acknowledged
Gas Optimizations
1 findings
0 fixed
1 acknowledged
Medium Risk1 finding
U64DynamicAddress Pricing cannot distinguish between assets
Severity
- Severity: Medium
Submitted by
red-swan
Description
When a
U64DynamicAddresstype is processed in theget_navfunction, it reads the nav from the first account in the uncheckedremaining_accountsarray. There are a few issues with how this is done.- Only the first of the user-provided
remaining_accountsis ever read from and used for all dynamic prices of the asset. - The only requirement for this account is that it be owned by a specified
nav_program_idaccount. Should thisnav_program_idown multiple accounts then any one of them could be passed and read as a price for any other assets in this process.
Recommendation
- Either iterate through the remaining_accounts or don't allow multiple dynamic pricing accounts for a single asset
- Determine if supporting dynamic accounts is required and whether one could not ask the data account owner for the price of the asset rather than directly querying the data account. Another possibility could include having the pricing data account owned by the swap program itself and tracking which account would go to which asset.
Low Risk6 findings
Global config initialization can be frontrun
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Sujith S
Description
There is no access control on the global config initialization. A malicious actor can frontrun the initialization with their own parameters. If this goes undetected, user funds could be at risk.
Recommendation
Consider one of the following:
- Restrict initialization to privileged accounts.
- Always ensure initialization scripts validate configurations after the transaction submission.
Asset type modification post-initialization causes denial of service on swaps
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
update_asset_config_account()function allows the protocol admin to modify the asset_type field of an AssetConfig account after initialization. This will break the swap implementation, causing swaps to fail.The
swap()function enforces strict asset type validation atswap.rs:// check that the asset types are correctrequire!(ctx.accounts.rwa_config.asset_type == AssetType::Rwa, ErrorCode::InvalidAssetType);require!(ctx.accounts.stable_config.asset_type == AssetType::Stable, ErrorCode::InvalidAssetType);When an admin changes the asset_type of an asset from its original designation (e.g., changing a Stable to an Rwa, or vice versa), all swaps involving that asset will permanently revert with an InvalidAssetType error. This effectively bricks all liquidity pools using that asset. However, LPs are not affected, as such validations do not happen in the withdrawal liquidity-related functions.
Recommendation
Make asset_type immutable after initialization by removing it from the
update_asset_config_account()function:pub fn update_asset_config_account( ctx: Context<UpdateAssetConfigAccount>, nav_data: Vec<NavData>, price_difference_bps: u16, // Remove asset_type parameter ) -> Result<()> { .... // Remove: asset_config.asset_type = asset_type; Ok(()) }If asset type updates must be supported for legitimate reasons, add comprehensive validation:
- Check that no active pairs exist using this asset before allowing type changes
- Add a time-lock mechanism requiring a delay before the change takes effect
- Emit events to warn LPs of the upcoming change
- Consider adding a separate "migration" function with stricter controls
No validation for combined fees causes transaction panic when total exceeds 100%
Severity
- Severity: Low
Submitted by
Sujith S
Description
The protocol validates individual fee parameters but fails to validate that combined fees (pair fees + protocol fees) don't exceed 100%, causing transaction panics during swaps.
- init_pair / update_pair
require!(redemption_fee_bps <= 9900, ErrorCode::OutOfRange); // Up to 99%require!(discount_rate_bps <= 9900, ErrorCode::OutOfRange); // Up to 99%- init_global_config
require!(protocol_fees_bps <= 9900, ErrorCode::OutOfRange); // Up to 99%As a result, the total fees calculated exceed the amount_in parameter during stable-to-asset swaps, resulting in an underflow.
PoC
Consider the following scenario:
- Admin sets protocol_fees_bps = 200 (2%)
- LP sets redemption_fee_bps = 9900 (99%)
- User attempts swap: 9900 + 200 = 10100 > 10000
- Transaction panics with a MathOverflow error instead of meaningful message
Recommendation
Add validations in all fee configuration functions to ensure the total_fee_bps remains less than 10,000. Or consider adding a validation in the
calculate_swap_results()function.Missing protocol_fee_bps upper bounds validation in update global config
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
update_global_config()function allows updating protocol_fees_bps without any validation, unlikeinit_global_config(), which enforces a maximum of 9900 BPS.As a result, the function allows setting protocol_fees_bps to any u16 value (up to 65535), breaking protocol_fee_bps assumptions throughout the protocol.
Recommendation
Consider adding the same validation used in
init_global_config():if let Some(protocol_fees_bps) = protocol_fees_bps {+ require!(protocol_fees_bps <= 9900, ErrorCode::OutOfRange); new_protocol_fees_bps = protocol_fees_bps;}Vault token accounts not closed in close_pair() instruction
Severity
- Severity: Low
Submitted by
Sujith S
Description
The close_pair instruction fails to close the SPL token vault accounts (stable_coin_vault_token_account and asset_token_vault_token_account) when the reference counter (used) reaches zero.
While the instruction properly:
- Transfers remaining tokens back to the LP
- Closes the UserVaultInfo PDA accounts
It does not close the actual SPL token vault accounts themselves, resulting in orphaned token accounts that permanently lock rent on-chain.
Recommendation
Add SPL token account closure to the
close_pair()instructionwhen used == 0:close_account( CpiContext::new_with_signer( ctx.accounts.token_program_stable.to_account_info(), CloseAccount { account: ctx.accounts.stable_coin_vault_token_account.to_account_info(), destination: ctx.accounts.admin.to_account_info(), authority: ctx.accounts.program_authority.to_account_info(), }, &[signer_seeds], ))?;Missing slippage protection in swap() function
Severity
- Severity: Low
Submitted by
Sujith S
Description
The
swap()function inswap.rslacks slippage protection mechanisms, exposing users to risk from price volatility, front-running attacks, and MEV exploitation. The function accepts only an amount parameter without any slippage tolerance check. Users cannot specify the minimum acceptable amount of tokens they expect to receive.Recommendation
Consider implementing comprehensive slippage protection by adding limit parameters to the
swap()function.Multiliquid
Cantina
The fixes look accurate, account for all possible scenarios, and allow users to protect their amountIn and amountOut. However, these two parameters are optional but should be provided to avoid MEV attacks.
Informational13 findings
Unused macros and error codes
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The codebase contains several instances of dead code that is defined but never used throughout the program:
-
Unused Macro The assert_range macro is defined and exported with #[macro_export] but has zero usages across the entire codebase.
-
Unused Error Code Variants Seven error code enum variants are defined, but never referenced in any instruction or validation logic:
- InvalidNewAdmin
- NotAdmin
- InvalidMintAddress
- MustProvidePriceDecimals
- MustProvideNavProgramIdOrNavAccountDiscriminator
- MustProvideNavPriceOffset
- LpFeeMustBePositive
Recommendation
- Remove the unused assert_range macro from
macro.rs - Remove all seven unused error code variants from
error.rsto maintain a clean error enumeration that only includes actively used error cases.
Missing token account type constraints in spl_helpers::transfer()
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
spl_helpers::transfer()function accepts generic AccountInfo parameters for the from and to accounts instead of strongly-typedInterfaceAccount<'info, TokenAccount>parameters.Current state:
- All nine usage instances (swap.rs, claim_fees.rs, close_pair.rs, add_liquidity.rs, remove_liquidity.rs) properly validate token accounts at the instruction level with
InterfaceAccount<'info, TokenAccount>and appropriate constraints. - Accounts are downgraded to AccountInfo when passed to the helper via
.to_account_info() - The helper relies entirely on the SPL Token Program validation during CPI.
While the current implementation is secure, it weakens Rust's type-safety guarantees. Future code modifications or new call sites could accidentally pass incorrect account types, with errors only caught at runtime during CPI execution rather than at compile-time or during Anchor validation.
Recommendation
- Change the helper function signature to enforce strong typing and remove downgrading to type AccountInfo while passing it to the helper in all usage instances.
from: &InterfaceAccount<'info, TokenAccount>,to: &InterfaceAccount<'info, TokenAccount>- Alternatively, add relevant documentation in the
spl_helpers::transferfunction to ensure this behavior is properly documented for future developers.
Unused version field in AssetConfig struct
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
AssetConfigstruct contains a version field that is defined but never used anywhere in the codebase. The version field is documented as "version of the NavData struct".Recommendation
Consider removing the unused field (or) implement version tracking if required and start from version 1 instead of 0.
Error code MathOverflow used instead of MathUnderflow for checked_sub() operations
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The math.rs module inconsistently uses error codes for arithmetic operations. Multiple instances of
checked_sub()(subtraction) incorrectly return MathOverflow instead of MathUnderflow, making debugging significantly harder.Recommendation
Update all
checked_sub()operations to use MathUnderflow error code.Typographical errors
Severity
- Severity: Informational
Submitted by
Sujith S
Description
- /// NavData is used to store info about the methpds to retrieve the NAV data for a specific asset+ /// NavData is used to store info about the methods to retrieve the NAV data for a specific asset - // DRY function to read the price from an account (expecting the price to be stored as au64)+ // DRY function to read the price from an account (expecting the price to be stored as u64)NavData enum documentation is incomplete and misleading
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The inline documentation for the
NavDataenum inasset_config.rsstates that "Currently we support three methods" for retrieving NAV data. However, the actual implementation supports four distinct methods. The fourth variant,PythPush, is completely omitted from the documentation comment.Recommendation
Update the inline documentation to reflect all four supported NAV data retrieval methods accurately.
Integer overflow in min_acceptable_nav calculation causes transaction failure for large NAV values
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
get_nav()function inasset_config.rsperforms a price difference validation check that is vulnerable to arithmetic overflow when NAV values are significant (has less price decimals).When
max_navis sufficiently large (e.g., 1e18 = 1e9 with zero decimals), the multiplication operationmax_nav.checked_mul(10000 - price_difference_bps as u64)overflows theu64type and returnsNone. The subsequent.unwrap()call then panics with the errorOption::unwrap()on a None value.This issue results in:
- Denial of Service: Any asset with a NAV above approximately 1.84 × 10^15 (with 9 decimals) will cause all transactions to fail.
- Unpredictable Failures: The issue only manifests when NAV reaches certain thresholds, making it difficult to diagnose.
- No Graceful Error Handling: The transaction panics rather than returning a proper error code
PoC
max_nav= 1_000_000_000_000_000_000 (1 quintillion, representing a price of 1e9 asset with 0 decimals) -price_difference_bps= 500 (5% tolerance)- Calculation: 1_000_000_000_000_000_000 × (10000 - 500) = 1_000_000_000_000_000_000 × 9500 - Result: 9.5 × 10^21, which exceeds
u64::MAX(18,446,744,073,709,551,615 ≈ 1.84 × 10^19) - Thechecked_mul()returnsNone, causing a panic
Recommendation
-
Replace all
.unwrap()calls with proper error handling using.ok_or(ErrorCode::MathOverflow)?which will return meaningful error. -
Add comprehensive tests with edge cases: Maximum safe NAV values, Minimum price_difference_bps values, and Various combinations that approach u64 limits.
-
Document maximum supported NAV values in the code comments.
Multiliquid
Fixed by 4cf5446b3adfa8d12d8dc7f86a7007d6f3ffbec5 (for now, we've added error handling with MathOverflow)
The likelihood is really close to 0 (most of the stables will have a value around $1, and for RWAs, we can't imagine having such a NAV. Since it is not something a random user (the NAV) could set, we don't see a security issue here either.
Cantina
While the newly added error checks will detect and revert on overflows with more explicit error messages, the protocol’s capacity to safely handle large NAV values remains constrained. It may still result in unintended or unpredictable behavior.
Function set_new_admin() does not check for duplicate assignment
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
set_new_admin()function lacks validation to ensure that the new admin address differs from the current admin address. This allows the current admin to unnecessarily set themselves as the pending admin again, wasting transaction fees and emitting misleading events without actual state changes.Recommendation
Add validation to prevent setting the new admin to the current admin address:
require!(new_admin != global_config.admin, ErrorCode::AlreadyAdmin);Swap input amount limited to ~18.45 tokens for 18-decimal tokens due to u64 type
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The swap instruction uses u64 to represent token amounts, which significantly restricts the maximum swappable amount for tokens with high decimal precision.
For tokens with 18 decimals, the maximum value of u64 (18,446,744,073,709,551,615) translates to only approximately 18.45 tokens in human-readable terms:
u64::MAX / 10^18 = 18,446,744,073,709,551,615 / 1,000,000,000,000,000,000 ≈ 18.45
This limitation severely restricts the protocol's usability for:
- High-value swaps involving tokens with 18 decimals
- Liquidity provision operations that require larger amounts
- Any operation where users need to transact more than ~18 tokens
The issue affects not just swap operations but potentially all instruction parameters that handle token amounts using u64, including
add_liquidity(),remove_liquidity(), and related functions.Recommendation
Replace u64 with u128 for all token amount parameters throughout the program. The u128 type provides sufficient range to handle tokens with up to 38 decimals at scale:
u128::MAX / 10^18 ≈ 340 trillion tokens
Redundant mint_address assignment in update_asset_config_account()
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
update_asset_config_account()function contains unnecessary computation where mint_address is assigned or validated.Since the asset_config account is a PDA derived with mint_address as a seed, the Anchor framework already enforces that the account can only be accessed with the correct mint_address used in derivation.
Any assignment or validation of this field is redundant and wastes compute units.
Recommendation
Remove the redundant mint_address assignment or validation from the update_asset_config_account function.
Redundant pause state initialization to default value
Severity
- Severity: Informational
Submitted by
Sujith S
Description
In the
init_pair()andinit_asset_config_account()functions, the pause state is explicitly set to false, which is already the default value for boolean fields in newly initialized accounts. This redundant assignment wastes compute units without providing any functional benefit.Recommendation
Remove the explicit pause state assignment to false in both
init_pair()andinit_asset_config_account()functions.Multiliquid
Use UncheckedAccount instead of AccountInfo
Severity
- Severity: Informational
Submitted by
Sujith S
Unchecked .unwrap() in SwapExecuted event emission
Severity
- Severity: Informational
Submitted by
Sujith S
Description
The
amount_in_for_vault.checked_add(protocol_fees).unwrap()could panic if the addition overflows.Recommendation
Consider using
.ok_orat the end of the checked_add to ensure the overflow is caught and reverted accordingly.
Gas Optimizations1 finding
Unnecessary CPI call when protocol fees are zero
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Sujith S
Description
The
swap()function in swap.rs makes CPI calls to transfer protocol fees even when the fee amount is zero, unnecessarily wasting compute units.Recommendation
Consider adding a conditional check to skip the transfer CPI when protocol fees are zero (only if protocol_fees can be zero). If it will never be set to zero, then adding an additional check is redundant and this finding could be safely "acknowledged".