Organization
- @Ondofinance
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Critical Risk
1 findings
1 fixed
0 acknowledged
High Risk
4 findings
4 fixed
0 acknowledged
Medium Risk
8 findings
8 fixed
0 acknowledged
Low Risk
30 findings
30 fixed
0 acknowledged
Informational
16 findings
15 fixed
1 acknowledged
Critical Risk1 finding
Infinite mint/redeem possible through signature manipulation
Severity
- Severity: Critical
≈
Likelihood: High×
Impact: High Submitted by
J4X
Description
The ondo protocol uses attestations to ensure that no unrestricted minting is possible. With each call to ``
The table below is taken from the official solana_secp256k1_program documentation.
Index Bytes Type Description 0 2 u16 signature_offset — offset to 64-byte signature plus 1-byte recovery ID. 2 1 u8 signature_offset_instruction_index — within the transaction, the index of the transaction whose instruction data contains the signature. 3 2 u16 eth_address_offset — offset to 20-byte Ethereum address. 5 1 u8 eth_address_instruction_index — within the transaction, the index of the instruction whose instruction data contains the Ethereum address. 6 2 u16 message_data_offset — offset to start of message data. 8 2 u16 message_data_size — size of message data in bytes. 10 1 u8 message_instruction_index — within the transaction, the index of the instruction whose instruction data contains the message data. In this table, we can see that each part of the struct also has a corresponding
instruction_index. However, looking at the code below, we can see that these are ignored during parsing and never checked afterward.// Parse offsets (all u16 are little-endian): let sig_off = u16::from_le_bytes([data[rd], data[rd + 1]]) as usize; let eth_off = u16::from_le_bytes([data[rd + 3], data[rd + 4]]) as usize; let msg_off = u16::from_le_bytes([data[rd + 6], data[rd + 7]]) as usize; let msg_len = u16::from_le_bytes([data[rd + 8], data[rd + 9]]) as usize; msg!( " Sig offset: {}, ETH offset: {}, Message offset: {}, length: {}", sig_off, eth_off, msg_off, msg_len ); require!(msg_len == 32, SecpError::WrongDigestLen); require!(msg_off + msg_len <= data.len(), SecpError::MalformedSecpIx); require!(eth_off + 20 <= data.len(), SecpError::MalformedSecpIx);As a result, the
secpinstruction could contain the intended digest. However, the actual verification performed by thesecp256k1_programwas based on data from a prior instruction. This prior instruction can be an instruction to a user-controlled program that contains a digest, a valid signature of the digest by the attacker, and the attacker's eth address at the same offsets as in thesecp256k1_program. This way, both would pass, and the program would mint, while no actual signing happened at all.POC
An exemplary attack could look like this. The user creates a custom program that accepts any input. He structures a batch of instructions as follows:
IX-Index program ix content 0 usersCustomProgram acceptAllInputIx1: Signature of randomMessage32BytesbyattackerEthAddress
2:attackerEthAddress
3:randomMessage32Bytes
The parts need to be spaced according to the offsets specified in the1 secp256k1_program secp256k1This will contain a header with some variable offsets, and for each of the parts, the instruction_indexwill point to IX0.
In the instructions data, the correct digest and eth pubkey of ondo must be placed at the correct offsets, the signature can be any value as it will never be checked2 ondo-gm mint_with_attestationThis will pass as secp256k1passed and contained the correct digest and eth address within its ix dataBy using this bundled IX, the attacker can mint/redeem as often as they want without any attestations.
Recommendation
We recommend ensuring that the
instructionindexes equal the instruction currently being checked.
High Risk4 findings
Privilege escalation via lamport transfer to role PDA in whitelist operations
Severity
- Severity: High
≈
Likelihood: High×
Impact: Medium Submitted by
Mario Poneder
Description
The
AddToWhitelistandRemoveFromWhitelistinstructions inwhitelist_operations.rsuseUncheckedAccountfor role validation, allowing privilege escalation by sending SOL to a precomputed PDA address.Implementation details:
#[account( seeds = [RoleType::ADMIN_ROLE_WHITELIST, admin.key().as_ref()], bump)]pub role_account: UncheckedAccount<'info>, // In function:require_gt!(self.role_account.lamports(), 0, ErrorCode::ConstraintAddress);Anchor's
UncheckedAccountwithseedsonly validates the address, not ownership or data. Furthermore, the code only checks lamports, not whether it's a validRolesaccountAttack path and impact:
- Attacker precomputes their role PDA address:
findProgramAddressSync([b"AdminRoleWhitelist", attacker.key()], programId). - Attacker sends SOL to that address (simple transfer, no account creation needed).
- Attacker has now gained
ADMIN_ROLE_WHITELISTprivileges.
This results in full control over the mint/redeem operations by whitelisting malicious/sanctioned addresses or removing genuine addresses from the whitelist at will, effectively bypassing compliance controls and user rate limiting, as well as blocking users from swapping.
Furthermore, the attacker can steal rent by closing
Whitelistaccounts, which is 0.12 USD at the current price, i.e. 120k USD per million whitelisted users.Recommendation
It is recommended to change
role_accountfromUncheckedAccounttoAccount<Roles>, e.g.:/// The Roles account verifying the admin has ADMIN_ROLE_WHITELIST/// # PDA Seeds/// - ADMIN_ROLE_WHITELIST/// - Admin's address#[account( seeds = [RoleType::ADMIN_ROLE_WHITELIST, admin.key().as_ref()], bump,)]pub roles: Account<'info, Roles>, // Changed from UncheckedAccountUnchecked confidence value allows for usage of non trustworthy oracle prices
Severity
- Severity: High
≈
Likelihood: High×
Impact: High Submitted by
J4X
Description
The protocol uses the Pyth oracle to ensure that no USDC depeg above 10% has happened when minting USDon for USDC.
let usdc_price = match usdc_price_update_info.key() { USDC_PYTH_ORACLE_ADDRESS => { // Fetch the feed ID for the USDC token price from its hex representation. let usdc_feed_id: [u8; 32] = get_feed_id_from_hex(USDC_PYTH_ID)?; // Deserialize `usdc_price_update_info` account data into PriceUpdateV2 struct let data = usdc_price_update_info.try_borrow_data()?; let usdc_price_update_data = PriceUpdateV2::try_deserialize(&mut &data[..])?; // Retrieve current USDC/USD price from Pyth oracle with freshness validation // This ensures we're using recent price data to prevent stale price attacks usdc_price_update_data .get_price_no_older_than( &Clock::get()?, self.usdon_manager_state.oracle_price_max_age, &usdc_feed_id, )? .price } _ => return err!(OndoError::UsdcOracleNotImplemented),}; // Validate that USDC price is above minimum thresholdrequire_gte!(usdc_price, MIN_PRICE, OndoError::UsdcBelowMinimumPrice);The
get_price_no_older_than()function will return aPricestruct./// A Pyth price./// The actual price is `(price ± conf)* 10^exponent`. `publish_time` may be used to check the recency of the price.#[derive(PartialEq, Debug, Clone, Copy)]pub struct Price { pub price: i64, pub conf: u64, pub exponent: i32, pub publish_time: i64,}In this struct the
confvalue means how far the confidence interval out of all the reported prices ranges. If this value is high it indicates a non clear price. As a result this price should not be trusted if the value passes a chosen threshold.This could lead to a non trustworthy price being used to still mint USDon while USDc might have deppeged significantly more severely.
Recommendation
We recommend checking the confidence value to be less than a value fitting the intended risk profile (5% is recommended in some tutorials).
Too low MIN_PRICE will lead to Ondo incurring significant losses in case of a USDC depeg
Severity
- Severity: High
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Description
The protocol implements safeguards to protect itself against a potential USDC depeg. Before interactions using USDC, the current Pyth price of USDC is fetched and checked to ensure it is not lower than
MIN_PRICE.// Validate that USDC price is above minimum thresholdrequire_gte!(usdc_price, MIN_PRICE, OndoError::UsdcBelowMinimumPrice);MIN_PRICEis currently set to 90c./// Minimum price threshold for USDC (in scaled units)pub const MIN_PRICE: i64 = 90_000_000;However, as a result, this will allow people to mint tokens for a potentially undercollateralized depegged USDC token until its price has crashed by 10%, which will take time. Especially for a high liquidity token like USDC, this price dump will take even longer.
As a result, this very low
MIN_PRICEwill allow for minting of USDon/GM for depegged USDC at a 1:1 ratio for a far longer than needed time.Recommendation
We recommend setting
MIN_PRICEsignificantly closer to 1 USD to ensure early pausing in the event of a depeg.Incorrect rounding direction in mint_with_attestation
Severity
- Severity: High
≈
Likelihood: Medium×
Impact: High Submitted by
n4nika
Description
In
mint_with_attestation, the amount the user has to pay is calculated based onpriceandamount. This calculation incorrectly rounds down, causing the user to pay less than he needs to.The affected operations are the
mul_divin the function'struecase:let amount_sent = mul_div(price, amount, PRICE_SCALING_FACTOR as u64)?;as well as both
mul_divandnormalize_decimalsin thefalsecase:let normalized_amount = normalize_decimals(amount, ctx.mint.decimals, usdc_mint_decimals)?; // Calculate the amount of USDC to be sent based on the pricelet amount_sent = mul_div(price, normalized_amount, PRICE_SCALING_FACTOR as u64)?;The impact is very miniscule in the
truecase (but still incorrect and needs to be fixed). In thefalsecase, however, especially the rounding ofnormalize_decimalscan be exploited in edge cases.The highest impact can be achieved when a user buys a very small amount of a very expensive stock. Looking at the ondo dashboard, the most expensive stock is
MELIonat a price of approximately2000 USD / share. Assuming any stocks may be added to ondo in the future, however, a very expensive example would beBRK-Aat a price of approximately760000 USD / share.Breaking down the calculation of
amount_sentyields the following:normalized_amount = amount / (10**3)amount_sent = (price * normalized_amount) / 1e9Taking the following values showcases the worst case senario:
amount = 1999price = 760000 * 1e9
This yields the following results:
with rounding:
normalized_amount = 1999 / 10**3 = 1.999 => 1amount_sent = (760000*1e9 * 1) / 1e9 = 760000 = 0.76 USDwithout rounding:
normalized_amount = 1999 / 10**3 = 1.999amount_sent = (760000*1e9 * 1.999) / 1e9 = 1519240 = 1.51924 USDAs we can see, rounding down when normalizing the amount can lead to up to a 50% loss in the worst case.
Summarized, the rounding can lead to the loss of
1 millionthof a share. With very highly priced shares this can become quite significant. It's important to note though, that the permissioned nature of the protocol (requiring attestations to mint) greatly reduces the exploitability.Recommendation
Consider rounding up instead of down in the mentioned cases.
Specifically, in the
truecase, themul_divshould round up. In thefalsecase, however it would make sense to first calculate theamount_sentand only afterwards normalize the decimals and round up there. This would minimize rounding errors since themul_divafternormalize_decimalsincreases the inaccuracy.This is because the operations are currently:
divide, multiply, divideand division before multiplication is advised against.Reordering the operations would yield:
multiply, divide, divide
Medium Risk8 findings
Attestation/Token creation process can be blocked
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: High Submitted by
J4X
Description
Both the
init_mint_internalas well as theinitialize_attestation_accountuse thecreate_account()function. Below, one can see the implementation ofcreate_account()in the Solana program.fn create_account( from_account_index: IndexOfAccount, to_account_index: IndexOfAccount, to_address: &Address, lamports: u64, space: u64, owner: &Pubkey, signers: &HashSet<Pubkey>, invoke_context: &InvokeContext, transaction_context: &TransactionContext, instruction_context: &InstructionContext,) -> Result<(), InstructionError> { // if it looks like the `to` account is already in use, bail { let mut to = instruction_context .try_borrow_instruction_account(transaction_context, to_account_index)?; if to.get_lamports() > 0 { ic_msg!( invoke_context, "Create Account: account {:?} already in use", to_address ); return Err(SystemError::AccountAlreadyInUse.into()); }Since this will return an error if the account holds any lamports, an attacker can precompute the attestation/mint PDA's address and send the minimum amount of lamports needed for an empty account to it. This is about 0.001 sol or about 12c at the current price. As a result, all calls to create these will revert.
Recommendation
We recommend using
allocate,transfer, andassignmanually to prevent this.initialize_user allows for rate limit bypass
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Description
When a user tries to mint/redeem tokens the
initialize_ondo_userwill be called and set the default limits for the user.#[inline(always)] pub fn initialize_ondo_user(&mut self, bump: u8) -> Result<()> { if self.ondo_user.owner != self.user.key() { self.ondo_user.owner = self.user.key(); self.ondo_user.mint = self.mint.key(); self.ondo_user.rate_limit = self.token_limit_account.default_user_rate_limit; self.ondo_user.limit_window = self.token_limit_account.default_user_limit_window; self.ondo_user.mint_capacity_used = Some(0); self.ondo_user.mint_last_updated = None; self.ondo_user.redeem_capacity_used = Some(0); self.ondo_user.redeem_last_updated = None; self.ondo_user.bump = bump; msg!("User initialized"); } Ok(()) }So if the owner was already set, no limits will be set. This leads to an issue as anyone can create a user account and set the owner using
initialize_user. This instruction also allows the user to set his limits to the max and not have any limits at all.Recommendation
We recommend restricting the
initialize_userix to admins, or instead of allowing custom rate limits use the defaults.Admin is not able to grant PAUSER_ROLE_GMTOKEN and UNPAUSER_ROLE_GMTOKEN role
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Description
The
ADMIN_ROLE_GMTOKENshould be able to grant the rolesMINTER_ROLE_GMTOKEN,PAUSER_ROLE_GMTOKEN, andUNPAUSER_ROLE_GMTOKENthrough theadd_gmtoken_rolefunction.require!( matches!(role, RoleType::MinterRoleGmtoken), OndoError::InvalidRoleType);However due to the check seen above, the admin can only grant one of the three.
Recommendation
We recommend also allowing for the granting of the
PAUSER_ROLE_GMTOKENandUNPAUSER_ROLE_GMTOKENroleValue of action will be rounded down leading to bypass of limit
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Description
To calculate the USD value of a mint/redeem, the
rate_limit_checkfunction calculates(price * tokenAmount) / priceScale.fn rate_limit_check( &mut self, price: u64, token_amount: u64, current_timestamp: i64, is_buy: bool,) -> Result<()> { let amount = mul_div(price, token_amount, GM_TOKEN_SCALING_FACTOR)?;However as this uses div it will round down by up to one USD per interaction. As a result mints/redeems with a value below 1USD will not affect the limits at all and all others will be rounded down, leading to more minting being possible than actually intended.
Recommendation
We recommend rounding up in the calculation.
Attestations can't be closed in edge case
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
J4X
Description
The protocol implements two mechanisms to close attestation accounts.
#[account( mut, address = attestation.creator)]pub recipient: SystemAccount<'info>,However, both of these require the
attestation.creatorto be a system account. This leads to issues if a PDA without data created an attestation. In that case, the attestation can be created; however, it can't be closed, as all calls to the close functions will revert. This will cause the rent funds to get stuck.Recommendation
We recommend setting the
recipientto an unchecked account.GM token pauser can also pause USDon due to shared mint authority and Pausable extension
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Mario Poneder
Description
The
PauseGmTokeninstruction is intended to pause GM token mints, but its constraints allow it to pause any Token‑2022 mint whose authority is the sharedMINT_AUTHORITY_SEEDPDA. Therefore, a holder ofPAUSER_ROLE_GMTOKENcan callpause_tokenwithmint = usdon_mintand successfully pause USDon as well.This contradicts the intended role-based access control separation.
Recommendation
It is recommended to restrict
PauseGmToken(and the correspondingResumeGmToken) to exclude the USDon mint explicitly, for example by adding a constraint tying inUSDonManagerState:#[account( mut, mint::authority = mint_authority, mint::token_program = token_program, constraint = mint.key() != usdon_manager_state.usdon_mint @ OndoError::InvalidInputMint,)]pub mint: InterfaceAccount<'info, Mint>; #[account( seeds = [USDON_MANAGER_STATE_SEED], bump = usdon_manager_state.bump,)]pub usdon_manager_state: Account<'info, USDonManagerState>;USDon UI multiplier can be modified by GM token UPDATE_MULTIPLIER_ROLE
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Mario Poneder
Description
The
UpdateScaledUiMultiplierinstruction is intended to adjust theScaledUiAmountmultiplier for GM tokens, but its constraints allow the same role to change the UI multiplier for USDon as well, because USDon and GM tokens share the sameMINT_AUTHORITY_SEEDPDA as mint authority. Therefore, any holder ofUpdateMultiplierRolecan callupdate_scaled_ui_multiplieron the USDon mint.Implications:
- The displayed value of USDon in wallets, dashboards, and internal tools that respect the
ScaledUiAmountmultiplier can be arbitrarily skewed, even though the raw on-chain balances don’t change. This can:- Confuse users and operators about actual USDon amounts.
- Make balances appear larger/smaller in some UIs, impacting perceived solvency or P&L.
- Complicate off-chain accounting and reconciliation if some systems use scaled amounts and others use raw base units.
- It contradicts the spec which positions
UpdateMultiplierRoleas a GM‑token‑only concern.
Recommendation
It is recommended to restrict
UpdateScaledUiMultiplierso it cannot target the USDon mint, for example by adding a constraint and state account:#[account( mut, mint::authority = mint_authority, mint::token_program = token_program, constraint = mint.key() != usdon_manager_state.usdon_mint @ OndoError::InvalidInputMint,)]pub mint: InterfaceAccount<'info, Mint>; #[account( seeds = [USDON_MANAGER_STATE_SEED], bump = usdon_manager_state.bump,)]pub usdon_manager_state: Account<'info, USDonManagerState>;set_ondo_user_rate_limit uses wrong default window
Severity
- Severity: Medium
≈
Likelihood: High×
Impact: Medium Submitted by
J4X
Description
When the
set_ondo_user_rate_limitfunction is called, and no limit window is set for the user, theDEFAULT_LIMIT_WINDOWconstant is used.pub fn set_ondo_user_rate_limit(&mut self, rate_limit: u64) -> Result<()> { // Set the rate_limit field self.ondo_user.rate_limit = Some(rate_limit); // If limit_window is not set or is zero, use default 3600 seconds (1 hour) if self.ondo_user.limit_window.is_none() || self.ondo_user.limit_window == Some(0) { self.ondo_user.limit_window = Some(DEFAULT_LIMIT_WINDOW); }However, this is potentially the wrong value, as for each gmtoken, a
default_user_limit_windowcan be defined in itsTokenLimitaccount.// Default user limit window for this tokenpub default_user_limit_window: Option<u64>,Recommendation
We recommend adapting the code as follows:
// If limit_window is not set or is zero, use default 3600 seconds (1 hour) if self.ondo_user.limit_window.is_none() || self.ondo_user.limit_window == Some(0) { if token_limit.default_user_limit_window.is_none(){ self.ondo_user.limit_window = Some(DEFAULT_LIMIT_WINDOW); } else { self.ondo_user.limit_window = token_limit.default_user_limit_window; } }
Low Risk30 findings
Creation of attestation PDA will not account for lamport balance
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
When creating an attestation PDA, the program will transfer the minimum balance needed for the account.
// Create the instruction to create the attestation accountlet ix = system_instruction::create_account( &self.user.key(), &self.attestation_id_account.key(), Rent::get()?.minimum_balance(space), space as u64, &crate::ID,);However this doesn't account for the account potentially already holding lamports. As a result the account might actually hold more lamports than needed.
Recommendation
We recommend only transferring enough lamports so that the minimum balance is achieved.
Attestation can be closed 30 seconds past creation
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The description of
close_attestation_accountdocuments the following:/// Close a single attestation account////// The attestation account must be older than 30 seconds to be closed./// The rent from the closed account is returned to the recipient (original creator).However, when looking at the actual implementation, one can see that it actually only enforces
require_gte!( Clock::get()?.unix_timestamp, self.attestation.created_at + ATTESTATION_EXPIRATION, OndoError::AttestationTooNew);So while the documentation states that for the valid path the requirement is
timestamp > createdAt + 30 secondsthe actual code implementstimestamp >= createdAt + 30 secondsRecommendation
We recommend enforcing
>instead of>=.Attestations can be reused by closing and recreating attestation PDAs before expiration
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Mario Poneder
Description
The implementation intends to prevent attestation replay by creating a unique
AttestationPDA perattestation_idon first use, and rejecting subsequent uses of the same ID. However, the combination of the attestation‑closure logic and the initialization logic allows an attestation to be closed and then reused while it is still within its expiration window.Implementation details:
-
Mint/Redeem path uses only
expirationand does not bind it toATTESTATION_EXPIRATION.During mint/redeem, the program enforces only:
// Check attestation expirationrequire!( current_timestamp < expiration, OndoError::AttestationExpired);There is no check that
expiration - current_timestamp < ATTESTATION_EXPIRATION, i.e. that the actual validity window is smaller thanATTESTATION_EXPIRATION. -
Attestation account creation treats “account empty” as unused.
The
initialize_attestation_accountfunction marks an attestation as consumed by creating a PDA and writing anAttestationstruct, but it only considers an attestation “already used” if the account is non‑empty and has lamports:if self.attestation_id_account.lamports() == 0 || self.attestation_id_account.data_is_empty(){ // create account and write Attestation { attestation_id, creator, created_at, bump } // ... Ok(())} else { Err(OndoError::AttestationAlreadyUsed.into())}If the PDA has 0 lamports or empty data, it is treated as unused and can be recreated.
-
Close instructions only require 30 seconds since
created_at, not actual attestation expiry.The close logic uses
ATTESTATION_EXPIRATION(30 seconds) only to gate when an attestation account can be closed:require_gte!( Clock::get()?.unix_timestamp, self.attestation.created_at + ATTESTATION_EXPIRATION, OndoError::AttestationTooNew);The path does not check the attestation’s
expirationparameter.
Attack path and impact:
Assuming an attestation which expires ≥ 30 seconds in the future (currently not rejected by program):
- Use an attestation once (mint or redeem).
- Wait ≥ 30 seconds (as per
ATTESTATION_EXPIRATION). - Close the corresponding
AttestationPDA (reclaiming rent). - Re‑use the same attestation (same
attestation_id, signature,price,amount,expiration) as long asexpirationis still in the future.
This contradicts the intended replay‑protection semantics that should prevent double‑spending.
Off‑chain logic might prevent attestations expiring ≥ 30 seconds in the future, but on‑chain enforcement is still insufficient.Time drift between on-chain and off-chain system:
Additionally, note that the
expirationfield used to gate attestation validity is likely derived from the off‑chain quoting system’s clock, not the Solana cluster’sClocksysvar. This means any clock drift or skew between the off‑chain signer and the Solana cluster can extend the effective on‑chain lifetime of an attestation beyond what is intended off‑chain, further widening the window in which a closed attestation account can be recreated and the attestation reused. Even if the off‑chain system tries to enforce a short validity window, inaccurate clocks can still result in on‑chain acceptance of attestations that the off‑chain system considers expired, exacerbating the replay risk without any explicit user error.Example: If the off-chain signer’s clock is 10 seconds ahead, it might issue an attestation with
expiration = now_off + 30, which on-chain looks likeexpiration = now_on + 40.
This extra 10 seconds of effective on-chain validity widens the window in which a closed attestation account can be recreated and the same attestation reused.Recommendation
It is recommended to enforce a maximum validity window on‑chain. At attestation use (mint/redeem), in addition to
current_timestamp < expiration, also checkexpiration - current_timestamp < ATTESTATION_EXPIRATION.Admin can't overwrite non-zero limit_window
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The
set_ondo_user_rate_limit()function can be used by the admin to adjust a user's rate limits.pub fn set_ondo_user_rate_limit(&mut self, rate_limit: u64) -> Result<()> { // Set the rate_limit field self.ondo_user.rate_limit = Some(rate_limit); // If limit_window is not set or is zero, use default 3600 seconds (1 hour) if self.ondo_user.limit_window.is_none() || self.ondo_user.limit_window == Some(0) { self.ondo_user.limit_window = Some(DEFAULT_LIMIT_WINDOW); } // Initialize rate_used fields if not already set if self.ondo_user.mint_capacity_used.is_none() { self.ondo_user.mint_capacity_used = Some(0); } if self.ondo_user.redeem_capacity_used.is_none() { self.ondo_user.redeem_capacity_used = Some(0); }This will only allow the admin to set the limit window to the default if it is none or 0. However if the user set it to something very low like one second, no changes can be made.
Recommendation
We recommend allowing the admin to set a custom
limit_window.Restriction on multisig usage as the upgrade authority
Severity
- Severity: Low
Submitted by
n4nika
Description
The
InitRolescontext struct uses theadminas the payer for the rent used to create the respective role account. This admin is enforced to be the upgrade authority of the program.Since this account holds a lot of power, it is common practice to use a multisig in its place.
#[account(mut)]pub admin: Signer<'info>, // [...]#[account( init, payer = admin, space = Roles::INIT_SPACE, seeds = [role.seed(), user.key().as_ref()], bump)]pub roles: Account<'info, Roles>,Solana restricts lamport transfers using the
system_program::transferinstruction from deducting lamports from accounts holding data. It is important to note that anchor'spayerconstraint uses this instruction to transfer lamports from the payer to the newly created account.This means any multisig which uses data-holding accounts as the signer is not usable with the program.
Of the currently popular solana multisigs which are open source,
Gokiuses a PDA holding data as the signer for multisig transactions.Recommendation
Consider adding a separate
payeraccount to theInitRolesstruct, using it solely to pay for therolesaccount's rent.#[account(mut)]pub admin: Signer<'info>, #[account(mut)]pub payer: Signer<'info>, // [...]#[account( init, payer = payer, space = Roles::INIT_SPACE, seeds = [role.seed(), user.key().as_ref()], bump)]pub roles: Account<'info, Roles>,GM token minter bypasses token-level mint limits
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Mario Poneder
Description
The
GmTokenMinter::mint_gminstruction allows any holder ofMINTER_ROLE_GMTOKENto mint arbitrary amounts of a GM token without enforcing the configured token-level mint limits.In the
GmTokenMinteraccount context#[account( seeds = [TOKEN_LIMIT_ACCOUNT_SEED, token_limit_account.mint.as_ref()], bump = token_limit_account.bump, has_one = mint @ OndoError::InvalidInputMint)]pub token_limit_account: Account<'info, TokenLimit>,the
token_limit_accountis required and correctly bound to themintaccount viahas_one = mint, but it is never read or enforced inmint_gm.As a result:
- Any
MINTER_ROLE_GMTOKENholder can mint unbounded amounts of a GM token, regardless of configuration in theTokenLimitaccount. - The presence of
token_limit_accountin the account context gives a false sense of enforcement, but it is effectively ignored in the logic. - This creates a privileged-path minting bypass relative to the rate/limit controls enforced in other flows.
Recommendation
It is recommended to load and enforce the
TokenLimitconfiguration before callingmint_toinGmTokenMinter::mint_gmusing the same capacity/rate-limit helpers as in other flows.USDon token minter/burner bypasses token-level mint limits
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: High Submitted by
Mario Poneder
Description
Both
USDonMinter::mint_usdonandUSDonBurner::burn_usdonrequire aTokenLimitPDA in their account context, but never actually use it to enforce any limits.For example, in the
USDonMinteraccount context#[account( seeds = [TOKEN_LIMIT_ACCOUNT_SEED, token_limit_account.mint.as_ref()], bump = token_limit_account.bump, has_one = mint @ OndoError::InvalidInputMint)]pub token_limit_account: Account<'info, TokenLimit>,the
token_limit_accountis never read, so any holder ofMINTER_ROLE_USDONorADMIN_ROLE_USDON(likely intended in the admin case) can mint unbounded USDon regardless of the configuredTokenLimitfor that mint. The same pattern exists forUSDonBurnerwithBURNER_ROLE_USDON.Recommendation
It is recommended to load and enforce the
TokenLimitstate before callingmint_toinUSDonMinter::mint_usdon, using the same capacity/rate‑limit helpers as in other flows. Apply similar logic toUSDonBurner::burn_usdon.
Reconsider if theADMIN_ROLE_USDONshould still be able to bypass these limits.Non ATA usdc_vault / usdon_vault will lead to all swaps reverting
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Both the
initialize_usdon_managerandset_usdc_vaultfunctions allow for setting theusdc_vaultaccount to an arbitrary address.pub fn set_usdc_vault(&mut self, new_usdc_vault: Pubkey) -> Result<()> { // Validate the new USDC vault address require!( new_usdc_vault != Pubkey::default(), OndoError::InvalidTokenAccount ); // Set the new USDC vault address self.usdon_manager_state.usdc_vault = new_usdc_vault; Ok(())}However, when the address is actually used in the
UsdcSwapContext, it must be the ATA ofusdon_manager_state./// The USDC vault storing USDC tokens received from users during swaps #[account( mut, associated_token::mint = usdc_mint, associated_token::authority = usdon_manager_state, constraint = usdc_vault.key() == usdon_manager_state.usdc_vault )] pub usdc_vault: Box<InterfaceAccount<'info, TokenAccount>>,This will cause all USDC actions to revert if the
usdc_vaultaddress is set to any address other than the ATA ofusdon_manager_state.The same issue occurs for the
usdon_vault.Recommendation
We recommend restricting
initialize_usdon_manager,set_usdc_vault, andset_usdon_vaultto only allow for the ATA ofusdon_manager_state.swap_usdon_to_usdc() allows for 0 value swap
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The swap functions are intended not to allow for zero-value swaps. To ensure this, the following check is added at the start of both.
// Validate that input amount is greater than zero require_gt!(amount_in, 0);However, in the
swap_usdon_to_usdc(), the decimal conversion will downcast anyvalue < 1000to 0, and thus still allow for a zero value swap while passing the first check.// Normalize decimals from USDon (9 decimals) to USDC (6 decimals) let normalized_amount_out = normalize_decimals(amount_in, self.usdon_mint.decimals, usdc_mint.decimals)?;Recommendation
We recommend ensuring that
normalized_amount_out > 0before continuing.Missing oracle-based swap pricing logic, swaps are always 1:1 despite oracle_price_enabled
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Medium Submitted by
Mario Poneder
Description
The spec describes an oracle mode for USDC↔USDon swaps where the exchange rate is derived from the Pyth USDC price and a scaling factor:
price_ratio = (usdc_price * PRICE_SCALING_FACTOR) / usdon_priceamount_out = (amount_in * price_ratio) / PRICE_SCALING_FACTORAdditionally, there is a fixed mode with
amount_out = amount_in. It also states thatoracle_price_enabledtoggles between “oracle vs fixed pricing”.Implementation:
USDonManagerStateexposes the expected configuration.- The swap functions in
token_manager.rsignore the oracle price for rate calculation and always perform a nominal 1:1 swap (only decimal-normalized). usdc_oracle_sanity_checkonly enforces freshness and a minimum USDC price, not a rate.
Consequences:
- Swaps are effectively always at 1:1 token units (adjusted for decimals), regardless of the oracle price.
oracle_price_enabledbehaves as a boolean gate for a sanity check, not as a mode switch between oracle and fixed pricing.- The documented oracle‑mode formula and the distinction between “oracle vs fixed” pricing are not implemented on-chain.
Recommendation
It is recommended to implement the documented oracle-based pricing in
swap_usdc_to_usdonandswap_usdon_to_usdc.Pyth oracle sanity check ignores price exponent
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Mario Poneder
Description
In the USDC oracle sanity check, the code reads the Pyth V2
PriceUpdateV2and uses only the rawpricefield, ignoring the associated exponent:- Pyth prices are represented as
(price, expo); the real price** isprice * 10^expo. - Here, only
priceis compared toMIN_PRICE, assumingMIN_PRICEis encoded using the same exponent as the current USDC feed. - This works today only because
MIN_PRICEwas chosen to match the current feed’s exponent, but it is fragile in case of future changes. Then the comparisonrequire_gte!(usdc_price, MIN_PRICE, …)can become semantically wrong, either failing valid prices or accepting under‑priced USDC, undermining the intended price floor.
Recommendation
It is recommended to explicitly handle the price exponent when performing sanity checks.
Attestation signer address is not validated
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Mario Poneder
Description
The attestation verification logic reads the expected Ethereum address directly from
GmTokenManagerState.attestation_signer_secpwithout checking that it has been configured to a non‑zero value:// Get the expected Ethereum address from the gmtoken manager statelet eth_address = self.gmtoken_manager_state.attestation_signer_secp;This field defaults to
[0u8; 20]whenGmTokenManagerStateis first created, and there is no guard that rejects the all‑zero address. If the manager is initialized (or later updated) without setting a valid signer, the program will still treat the state as “configured”, but no real attestation can ever pass the secp check, effectively creating a self‑inflicted DoS of all trading flows that rely on attestations.Recommendation
It is recommended to add a validation that the address is non‑zero:
require!( eth_address != [0u8; 20], OndoError::AttestationSignerEthAddressNotSet);Minting/Redeeming can not be used with non-ATA token accounts
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Both the
UsdcSwapContextand theUSDonSwapContextrestrict the token accounts provided by the users to be their corresponding ATAs./// The user's USDon token account#[account( mut, associated_token::mint = usdon_mint, associated_token::authority = user, associated_token::token_program = token_program,)]pub user_usdon_token_account: Box<InterfaceAccount<'info, TokenAccount>>,This blocks users from paying with regular token accounts.
Recommendation
We recommend allowing also for non ATA accounts.
USDC accounts don't verify correct token program.
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Neither the
user_usdc_token_accountnor theusdc_vaultverifies that the SPL token program owns them./// The user's USDC token account#[account( mut, associated_token::mint = usdc_mint, associated_token::authority = user,)]pub user_usdc_token_account: Box<InterfaceAccount<'info, TokenAccount>>,#[account( mut, associated_token::mint = usdc_mint, associated_token::authority = usdon_manager_state, constraint = usdc_vault.key() == usdon_manager_state.usdc_vault)]pub usdc_vault: Box<InterfaceAccount<'info, TokenAccount>>,For all other token2022 accounts, this is implemented.
Recommendation
We recommend adding a constraint that checks the token program.
oracle_price_max_age not checked against MAX_AGE_UPPER_BOUND on initialization
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
When the
oracle_price_max_agegets updated in theset_oracle_price_max_agefunction, it is checked as follows.// Validate the new oracle price max agerequire_gt!(oracle_price_max_age, 0, OndoError::InvalidOraclePriceMaxAge); // Ensure it does not exceed the upper boundrequire_gte!( MAX_AGE_UPPER_BOUND, oracle_price_max_age, OndoError::InvalidOraclePriceMaxAge);This ensures that
0 <oracle_price_max_age <= MAX_AGE_UPPER_BOUND. However, the check on initialization is only this:require_gt!(oracle_price_max_age, 0, OndoError::InvalidOraclePriceMaxAge);This only enforces
0 <oracle_price_max_agewith no upper bound. As a result the price could actually be greater thanMAX_AGE_UPPER_BOUNDRecommendation
We recommend adding a check for the
oracle_price_max_age <= MAX_AGE_UPPER_BOUND.usdc_price_update could be zero
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
When updating the
usdc_price_updateusing theset_usdc_price_update_addressthe program ensures that the address is not zero.pub fn set_usdc_price_update_address( &mut self, new_price_update_address: Pubkey,) -> Result<()> { // Validate the new price update address require!( new_price_update_address != Pubkey::default(), OndoError::InvalidOraclePriceAddress );However, in the initializer, any address is passed without a check against it being zero.
// Write data to the USDonManagerState accountself.usdon_manager_state.set_inner(USDonManagerState { owner: self.admin.key(), usdon_mint, oracle_price_enabled, oracle_price_max_age, usdc_price_update, usdc_vault, usdon_vault, bump: bumps.usdon_manager_state,});Recommendation
We recommend verifying that the
usdc_price_update != Pubkey::default()Zero usdon_mint can be intialized
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The initializer of the
InitializeUSDonManagerverifies all addresses besides theusdon_mintto be non-zero.// Validate vault addressesrequire!( usdc_vault != Pubkey::default() && usdon_vault != Pubkey::default(), OndoError::InvalidVault); // Write data to the USDonManagerState accountself.usdon_manager_state.set_inner(USDonManagerState { owner: self.admin.key(), usdon_mint, oracle_price_enabled, oracle_price_max_age, usdc_price_update, usdc_vault, usdon_vault, bump: bumps.usdon_manager_state,});This could allow for accidentally intializing the account with a 9 mint, which also couldn't be changed post deployment.
Recommendation
We recommend checking that
usdon_mint != 0in the intializer.USDC mint constraint is commented out in UsdcSwapContext
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Mario Poneder
Description
In the USDC swap context, the
usdc_mintaccount is intended to be constrained to the USDC mint on mainnet, but the constraint is commented out with a “remember to uncomment for mainnet” comment:/// The USDC mint (SPL Token)#[account( mint::token_program = spl_token_program, //constraint = usdc_mint.key() == USDC_MINT uncomment for mainnet (use for devnet/testnet))]pub usdc_mint: Box<InterfaceAccount<'info, Mint>>,This relies on a manual code edit to enforce the correct USDC mint, which is error‑prone and easy to forget, especially across deployments or refactors.
Recommendation
It is recommended to replace the commented constraint with a compile‑time configuration using Cargo features or explicit environment flags. For example:
#[account( mint::token_program = spl_token_program, #[cfg(feature = "mainnet")] constraint = usdc_mint.key() == USDC_MINT @ OndoError::InvalidUsdcMint)]pub usdc_mint: Box<InterfaceAccount<'info, Mint>>;GM token admin mint cannot target PDA recipients
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Mario Poneder
Description
The
GmTokenMinteradmin mint instruction cannot mint GM tokens to PDA-owned accounts, only to system-owned accounts. The recipient is constrained as aSystemAccount<'info>. Anchor’sSystemAccountenforces thatuseris owned by the system program, which PDAs (owned by the program) are not.Consequences:
- Admins cannot mint GM tokens directly to program‑owned PDAs (e.g. treasury PDAs, custodial accounts, vaults), only to EOAs.
- Any desired minting to PDA‑controlled addresses must go via an intermediate EOA + transfer, which may conflict with operational or compliance requirements.
Recommendation
It is recommended to relax the recipient type to allow PDAs. For example, change
pub user: SystemAccount<'info>to a more generalUncheckedAccount<'info>orAccountInfo<'info>with explicit ownership checks if needed.Incorrect mint used in swap_usdc_to_usdon
Severity
- Severity: Low
Submitted by
n4nika
Description
When
swap_usdc_to_usdonnormalizes theamount_outto USDC decimals, the wrong mint is used forto_decimals.let normalized_amount_out = normalize_decimals(amount_in, usdc_mint.decimals, self.mint.decimals)?;The correct one would be
self.usdon_mint.decimalssince the decimals are normalized fromUSDCtoUSDon. Since the decimals ofUSDonand GM tokens are currently the same, the calculation still returns the correct result but should still be fixed since it's technically incorrect.Recommendation
Consider changing
self.mint.decimalstoself.usdon_mint.decimals.Missing mint capabilities for AdminMintRoleGmtokenManager
Severity
- Severity: Low
Submitted by
n4nika
Description
According to the documentation, the
AdminMintRoleGmtokenManageris supposed to be able to mint GM tokens.Role Seed Capabilities AdminMintRoleGmtokenManager b"AdminMintRoleGmtokenManager" Administrative mints Looking at the
GmTokenMintercontext struct, however, only theMINTER_ROLE_GMTOKENrole can callmint_gm:pub struct GmTokenMinter<'info> { /// The operator minting tokens, pays for destination account if needed #[account(mut)] pub operator: Signer<'info>, #[account( seeds = [RoleType::MINTER_ROLE_GMTOKEN, operator.key().as_ref()], bump = roles.bump, )] pub roles: Account<'info, Roles>, // [...]}Recommendation
Consider allowing the
AdminMintRoleGmtokenManagerto mint GM tokens.USDon guardian cannot remove roles after giving them
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Medium Submitted by
n4nika
Description
Looking at how the
RoleType::MinterRoleUsdon,RoleType::PauserRoleUsdonandRoleType::BurnerRoleUsdonare used, it is apparent that those roles can be granted by theGUARDIAN_USDONbut not revoked by him anymore since the only instruction using these roles isinit_usdon_roles.pub fn init_usdon_roles(&mut self, role: RoleType, bumps: &USDonInitRolesBumps) -> Result<()> { require!( matches!( role, RoleType::MinterRoleUsdon | RoleType::PauserRoleUsdon | RoleType::BurnerRoleUsdon ), OndoError::InvalidRoleType ); // [...]}Looking at all other code segments giving roles, there is always another codepath allowing removal of the given roles except for this one.
Recommendation
Consider adding a function
remove_usdon_roles, allowing the guardian to take away given roles again.Several defined roles are unused in the Solana program
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Mario Poneder
Description
The Solana program defines multiple
RoleTypevariants and seeds that are never referenced outsideroles.rs, i.e. no instruction uses them for access control or behavior:TokenFactoryRolePauserRoleTokenManagerRegistrarAdminRoleTokenManagerRegistrarPauseTokenRoleAdminRolePauseTokenComplianceOwnerRoleOwnerIssuanceHoursRole
The Solana specification and the Solidity reference design both describe functionality mapped to these roles (e.g. TokenManagerRegistrar pausing/config, compliance owner, issuance‑hours owner, token‑pause admin), but there is no corresponding implementation on Solana.
This creates a specification/implementation gap and can mislead integrators.Recommendation
It is recommended to decide per role whether it should be implemented or removed.
Defined but unused error codes indicate missing or incomplete validations
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
Mario Poneder
Description
Several error variants are defined in
errors.rsbut are never used anywhere else in the Solana program. This suggests that some intended validations or safety checks are missing or only partially implemented:- Swap / pricing related:
InvalidOutputMintSlippageExceededInvalidMintsTokenSwapPaused
- Access control / compliance related:
AddressAlreadyInRoleBlocklistNotInitializedUserNotWhitelisted
- Attestation / signature diagnostics:
InvalidInstructionIndexAttestationSignerEthAddressNotSetPubkeyRecoveryFailedEthAddressRecoveryFailedEthAddressMismatchInvalidSignatureParams
Overall, these unused error codes show a mismatch between documented/intended behavior and what is actually enforced on‑chain, and they can mislead integrators into assuming certain protections (slippage limits, swap‑level pause, blocklist, richer signature diagnostics) exist when they do not.
Recommendation
It is recommended for each unused error to either implement the intended validation or remove the error to reflect the actual behavior.
init_usdon_roles misses RoleGranted event
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
All of the role setting functions emit the
RoleGrantedevent.// Emit event for role granted emit!(RoleGranted { role, grantee: user, granter: self.admin.key(), });However on the
init_usdon_rolesfunction the event is not emitted.Recommendation
We recommend adding an emit.
Sanity checker can be initialized with zero last_price
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
When setting the last price of the sanity checker using
set_last_price()it is checked that the price needs to be greater than 0.pub fn set_last_price(&mut self, last_price: u64) -> Result<()> { require!(last_price > 0, OndoError::InvalidPrice);However when initializing the sanity checker this is not checked.
// Write to the sanity check accountself.sanity_check.set_inner(OracleSanityCheck { last_price, mint: self.mint.key(), allowed_deviation_bps, max_time_delay, price_last_updated: Clock::get()?.unix_timestamp, bump: bumps.sanity_check,});Recommendation
We recommend adding a check to the initializer to ensure that
last_price > 0.mint is missing token program check
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Both the
USDonSwapContextas well as theUsdcSwapContextinclude themintaccount in their context. This account is the gm token that the swap/redeem will be done for./// The GM Token mint involved in the swap#[account( mut, mint::authority = mint_authority,)]pub mint: Box<InterfaceAccount<'info, Mint>>,The account constraints however never check the token program of these mints. As a result a incorrect mint could be passed. This is currently mitigated by other restrictions, but it's highly recommended to always check the token program on every mint/token account.
Recommendation
We recommend adding a check for the token program.
Users will loose up to 999 lamports of USDon on each USDC redemption
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
On a USDC redemption, the user is first minted the corresponding value in USDon.
mint_to( CpiContext::new_with_signer( ctx.token_program.to_account_info(), MintTo { mint: ctx.usdon_mint.to_account_info(), to: ctx.user_usdon_token_account.to_account_info(), authority: ctx.mint_authority.to_account_info(), }, signer_seeds, ), mint_amount,)?;Afterwards a swap will be called that will first noralize the amount to USDC decimals:
let normalized_amount_out = normalize_decimals(amount_in, self.usdon_mint.decimals, usdc_mint.decimals)?;Afterwards it will draw the full
mint_amountfrom the user and transfer himnormalized_amount_outof USDC.transfer_checked( CpiContext::new( self.token_program.to_account_info(), TransferChecked { from: self.user_usdon_token_account.to_account_info(), mint: self.usdon_mint.to_account_info(), to: self.usdon_vault.to_account_info(), authority: self.user.to_account_info(), }, ), amount_in, self.usdon_mint.decimals, )?; // Step 2: Transfer USDC tokens from protocol vault to user // This releases USDC from the protocol's vault to the user's account if normalized_amount_out != 0 { transfer_checked( CpiContext::new_with_signer( self.spl_token_program .as_ref() .ok_or(OndoError::TokenProgramNotProvided)? .to_account_info(), TransferChecked { from: self .usdc_vault .as_ref() .ok_or(OndoError::InvalidTokenAccount)? .to_account_info(), mint: usdc_mint.to_account_info(), to: self .user_usdc_token_account .as_ref() .ok_or(OndoError::InvalidTokenAccount)? .to_account_info(), authority: self.usdon_manager_state.to_account_info(), }, &[&[USDON_MANAGER_STATE_SEED, &[self.usdon_manager_state.bump]]], ), normalized_amount_out, usdc_mint.decimals, )?; }This leads to the user effectively overpaying for USDC. The two tokens should be pegged at a 1:1, but in this case, the user will lose up to 999 lamports of USDon. If, for example, the
mint_amount == 1999, the conversion would normalize this tonormalized_amount_out == 1. However, it would draw the full1999lamports of USDon from the user, which would be worth1.999 USDC. So the user would, in that case, lose the999lamports of USDon to the protocol.Recommendation
We recommend only drawing
normalize_decimals(normalized_amount_out, usdc_mint.decimals, self.usdon_mint.decimals)from the USDon vault, which will let the user keep the rounding losses.Type conversion can lead to unexpected behavior
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
n4nika
Description
Both the user's and any GM token's
limit_windowcan be set by theADMIN_ROLE_GMTOKEN_MANAGERup tou64::MAX. The problem is that setting it to any value larger thani64::MAXfor an account will freeze any interactions with that account.This is because in
calculate_capacity_used, which is used bycheck_token_rate_limitandcheck_user_rate_limit, thelimit_windowis cast to ani64andi64::try_from(limit_window)errors for any value larger thani64::MAX:if time_since_last_update >= i64::try_from(limit_window)? {Recommendation
Consider casting
time_since_last_update(to au64) for the check instead oflimit_window. This is safe sincetime_since_last_updateshould never be negative in this context anyways.Signature verification has more restrictions than intended
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Low Submitted by
n4nika
Description
The current implementation of the signature verification in
verify_secp256k1_ixis supposed to pass whenever there is "at least one valid secp instruction in the transaction". Right now this is not the case due to two bugs.Iterating over 20 instructions
fn verify_secp256k1_ix( &self, ix_sysvar: &AccountInfo, expected_digest32: &[u8; 32], expected_eth_address20: [u8; 20],) -> Result<()> { // Iterate through the instructions in the sysvar to find a matching secp256k1 instruction for i in 0..20 { // [...] } err!(SecpError::MissingOrMismatchedSecpIx)}verify_secp256k1_ixiterates over a maximum of 20 instructions. Since it's technically possible to have transactions with more than 20 instructions in solana, such a transaction would fail even if it's valid and contains a valid secp instruction.Only first instruction considered
Looking at
secp_matches, the function returns an error if the passed instruction does not pass validationfn secp_matches( &self, ix: &Instruction, digest: &[u8; 32], eth_addr: [u8; 20],) -> Result<bool> { // [...] require!(!data.is_empty(), SecpError::MalformedSecpIx); require!(data[0] == 1, SecpError::WrongSigCount); // [...] require!(data.len() >= rd + 11, SecpError::MalformedSecpIx); // [...] require!(msg_len == 32, SecpError::WrongDigestLen); require!(msg_off + msg_len <= data.len(), SecpError::MalformedSecpIx); require!(eth_off + 20 <= data.len(), SecpError::MalformedSecpIx); // [...] require!(msg == digest, SecpError::DigestMismatch); require!(eth_addr_in_ix == eth_addr, SecpError::AddressMismatch); Ok(true)}This means
verify_secp256k1_ixwill error if the first found secp instruction is not the one it's looking for, even if there is a valid one later on in the instruction list.if self.secp_matches(&ix, expected_digest32, expected_eth_address20)? { return Ok(());}Recommendation
Consider changing the semantics of the signature verification, enforcing that signature instructions must be one index before the instruction they are verified in.
For example, if a transaction contains two
redeem_for_usdoninstructions, one at index 2 and the other one at index 5, then the corresponding signature instructions must be at index 1 and 4 respectively. This would simplify the signature check and allow for bundling of mints/redemptions.
Informational16 findings
Role initializing/closing is dependent on mutability of program
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
n4nika
Description
Currently the program's upgrade authority is taken as the highest authority in the program since it is the only one capable of giving/taking admin roles.
#[account(mut)]pub admin: Signer<'info>, // [...] #[account( constraint = program_data.upgrade_authority_address == Some(admin.key()) @ OndoError::InvalidUser)]pub program_data: Account<'info, ProgramData>,The problem with this approach is that in case the decision is made to make the program immutable, this check can never be passed anymore since making a program immutable sets its
upgrade_authority_addresstoNone.Recommendation
In case the program will definitely never be made immutable, this is fine. If this might be done, however, consider adding an admin role with the same privileges as the upgrade authority in order to have the possibility to manage admin roles even if the program is made immutable.
Tokens with transfer-allowed == false can still be transferred
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The
TokenLimitaccount is used to constrain mints. It includes thetransfers_allowedbool, which should disable transfers if set to false.// Whether transfers are allowed for this tokenpub transfers_allowed: bool,However, this value is actually never checked anywhere. Thus, transfers for tokens with this set to false will work without any issues.
Recommendation
We recommend disabling transfers for tokens with this value set.
Misleading is_paused parameter name for enable_oracle_price
Severity
- Severity: Informational
Submitted by
Mario Poneder
Description
The
enable_oracle_priceadmin instruction uses a parameter namedis_paused, but it directly sets theoracle_price_enabledflag, which inverts the intuitive meaning of the name and is inconsistent with the rest of the pause naming in the codebase.In the admin implementation:
pub fn enable_oracle_price(&mut self, is_paused: bool) -> Result<()> { // Set the oracle price enabled state self.usdon_manager_state.oracle_price_enabled = is_paused; Ok(())}and exposed from the program:
pub fn enable_oracle_price(ctx: Context<USDonManagerAdmin>, is_paused: bool) -> Result<()> { ctx.accounts.enable_oracle_price(is_paused)}Recommendation
It is recommended to rename the parameter to e.g.
oracle_price_enabled: bool(and update bothlib.rsandusdon_manager_admin_operations.rs), so the call site matches the stored field and avoids inversion/confusion.Incorrect documentation of PauseGmToken
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The comment above the
PauseGmTokencontext states "RequiresUNPAUSER_ROLE_GMTOKENrole" however actually the caller needs to hold thePAUSER_ROLE_GMTOKENrole./// The Roles account verifying the pauser has PAUSER_ROLE_GMTOKEN /// # PDA Seeds /// - PAUSER_ROLE_GMTOKEN /// - Pauser's address #[account( seeds = [RoleType::PAUSER_ROLE_GMTOKEN, pauser.key().as_ref()], bump, )] pub roles: Account<'info, Roles>,Recommendation
We recommend adapting the documentation to "Requires
PAUSER_ROLE_GMTOKENrole".Incorrect documentation of MAX_SECONDS_EXPIRATION
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The
MAX_SECONDS_EXPIRATIONconstant is described as "Maximum allowed attestation expiration time (1 year in seconds)". However, it actually restricts the expiry of the price updates, not the attestations.Recommendation
We recommend adapting the comment to "Maximum allowed price delay"
Incorrect documentation of GmTokenManagerAdminGlobalPauser
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The
GmTokenManagerAdminGlobalPausercontext's functionality is described as "/// Unpause subscriptions/redemptions for all GM Tokens". However, actually, the function handles both pausing and unpausing.Recommendation
We recommend adapting the comment to "/// Pause/Unpause subscriptions/redemptions for all GM Tokens"
trading_hours_offset missing in initialize_gmtoken_manager comment
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The NatSpec comment for the
initialize_gmtoken_managerfunction is missing a description for thetrading_hours_offsetargument./// Initialize the GmTokenManagerState account /// # Arguments /// * `factory_paused` - Whether the GM Token factory should start in a paused state /// * `redemptions_paused` - Whether redemptions should start in a paused state /// * `subscriptions_paused` - Whether subscriptions should start in a paused state /// * `attestation_signer_secp` - The secp256k1 Ethereum address of the attestation signer (20 bytes) /// * `bumps` - The PDA bumps for account derivation /// # Returns /// * `Result<()>` - Ok if the GmTokenManagerState is successfully initialized, Err otherwise pub fn initialize_gmtoken_manager( &mut self, factory_paused: bool, redemption_paused: bool, minting_paused: bool, attestation_signer_secp: [u8; 20], trading_hours_offset: i64, bumps: &InitializeGmTokenManagerBumps, ) -> Result<()> {Recommendation
We recommend adding a description for the
trading_hours_offsetargument.Metadata update authority set to program PDA but no update path implemented
Severity
- Severity: Informational
Submitted by
Mario Poneder
Description
When deploying new mints in
token_factory.rs, the Token‑2022 metadata is initialized with the program’s mint authority PDA as both mint authority and update authority.However, the current codebase does not implement any CPI to update metadata, so in practice metadata is never changed on‑chain. The configuration thus sits in an ambiguous state:
- To users, it may look like metadata is immutable.
- To developers, it is technically upgradable by future program changes (since the program holds the update authority PDA), even though no current path exists.
This is not an immediate exploit, but is important for understanding trust and upgrade assumptions around token metadata.
Recommendation
It is recommended to decide explicitly whether metadata should be immutable or upgradable by the program.
Unnecessary role checks for AdminRoleGmtokenManager
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Both the
initialize_token_limitand theset_token_limitfunctions enforce the following check.require!( self.roles.role == RoleType::AdminRoleGmtokenManager, OndoError::AddressNotFoundInRole);However the role being the correct one is already verified based on the seed.
#[account( seeds = [RoleType::ADMIN_ROLE_GMTOKEN_MANAGER, admin.key().as_ref()], bump = roles.bump,)]pub roles: Account<'info, Roles>,Recommendation
We recommend removing the unnecessary checks.
Incorrect comment in sanity checker
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The following check in the sanity check is described as "Validate last price".
// Validate last pricerequire!( max_time_delay <= MAX_SECONDS_EXPIRATION, // 1 year lifetime in days, to be adjusted OndoError::InvalidMaxTimeDelay);However this actually validates the price delay.
Recommendation
We recommend changing this to "// Validate price delay".
Incorrect event emission in set_token_limit
Severity
- Severity: Informational
Submitted by
n4nika
Description
In
set_token_limit, at the end an event is emitted indicating the newly set values.emit!(RateLimitTokenSet { token: self.mint.key(), limit: self.token_limit.rate_limit, limit_window: self.token_limit.limit_window,});This is not entirely correct, however, since it is possible that
self.token_limit.rate_limitorself.token_limit.limit_windowis set toNonein which case the values' default values are used instead. Looking atinitialize_token_limit, it is correctly differentiating between the two cases:emit!(RateLimitTokenSet { token: self.mint.key(), limit: if self.token_limit.rate_limit.is_some() { self.token_limit.rate_limit } else { self.token_limit.default_user_rate_limit }, limit_window: if self.token_limit.limit_window.is_some() { self.token_limit.limit_window } else { self.token_limit.default_user_limit_window },});Recommendation
Consider changing the event emission in
set_token_limitto match the one ininitialize_token_limit.Incorrect documentation of mint_usdon and burn_usdon
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
Both the
mint_usdonandburn_usdonfunctions state that they can only be called by either the mint or burn role./// Mint USDon tokens (admin function) /// Signer must have the MINTER_ROLE_USDON role pub fn mint_usdon(ctx: Context<USDonMinter>, amount: u64) -> Result<()> { ctx.accounts.mint_usdon(amount, ctx.bumps.mint_authority) } /// Burn USDon tokens (admin function) /// Signer must have the BURNER_ROLE_USDON role pub fn burn_usdon(ctx: Context<USDonBurner>, amount: u64) -> Result<()> { ctx.accounts .burn_usdon(amount, ctx.bumps.permanent_delegate) }However actually they can both also be called by the
ADMIN_ROLE_USDON.Recommendation
We recommend adapting the documentation to state that
ADMIN_ROLE_USDON.Missing access control documentation on initialize_usdon_manager function
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The
initialize_usdon_managerfunction is restricted so it can only be called by theGUARDIAN_USDON.#[account( seeds = [RoleType::GUARDIAN_USDON, admin.key().as_ref()], bump = roles.bump,)]pub roles: Account<'info, Roles>,However compared to the other functions in
lib.rsthis is not documented./// Initialize the USDon manager state /// /// Sets up the manager with the USDon mint, initial price, oracle configuration, /// and vault addresses for USDC and USDon tokens.Recommendation
We recommend adding the following comment:
/// Signer must have the GUARDIAN_USDON roleIncorrect role comments for sanity checker in lib.rs
Severity
- Severity: Informational
≈
Likelihood: High×
Impact: Low Submitted by
J4X
Description
The comments for the sanity checker functions in the
lib.rsfile mention the rolesSETTER_ROLE_SANITY_CHECK,ADMIN_ROLE_SANITY_CHECKandCONFIGURER_ROLE_SANITY_CHECK. However actually the roles are calledSETTER_ROLE_ONDO_SANITY_CHECK,CONFIGURER_ROLE_ONDO_SANITY_CHECKandADMIN_ROLE_ONDO_SANITY_CHECK.Recommendation
We recommend adapting the comments to show the correct roles.
rate_limit_check should use PRICE_SCALING_FACTOR
Severity
- Severity: Informational
Submitted by
n4nika
Description
rate_limit_checkscales the amount it uses to check the rate limt usingGM_TOKEN_SCALING_FACTOR. The functions usingrate_limit_check(mint_with_attestationandredeem_with_attestation), however, scale the amount usingPRICE_SCALING_FACTOR.This means that in case the two differed, the rate limits and the actual value could deviate from each other. Currently these two are set to the same value though, making this an informational remark.
Recommendation
Consider using the same scaling factor (
PRICE_SCALING_FACTOR) inrate_limit_checkand*_with_attestationand removingGM_TOKEN_SCALING_FACTORsicne it's unused otherwise anyways.closer unnecessarily mutable in CloseAttestationAccount
Severity
- Severity: Informational
Submitted by
n4nika
Description
In the
CloseAttestationAccountcontext struct, thecloseris marked as mutable which is not necessary and should be avoided if not needed.pub struct CloseAttestationAccount<'info> { /// The user closing the attestation account #[account(mut)] pub closer: Signer<'info>, // [...]}Recommendation
Consider removing
#[account(mut)]from the signer (closer)