Skip to content
$1,000,000 in security audit grants are live now, Apply here →

Security review · December 2025

JupUSD Stablecoin

for Jupiter

Jupiter engaged Guardian to review the security of their Jup-Stablecoin Codebase. From the 17th of November to the 24th of November, a team of 4 auditors reviewed the source code in scope.

Published
Review window
November 17 to 24, 2025
Rounds
Main Review, Remediation Review
Language
Rust
Chains
Solana
Sector
Stablecoins
  • 0 Critical
  • 0 High
  • 0 Medium
  • 9 Low
  • 9 Informational

15 resolved · 3 acknowledged

Scope

Overview

Jupiter engaged Guardian to review the security of their Jup-Stablecoin Codebase. From the 17th of November to the 24th of November, a team of 4 auditors reviewed the source code in scope.

Findings 18

Main Review

14 findings
  1. L-01 Low Slot-timestamp Mismatch Breaks Price Fetching DoS Resolved
    Location
    oracle.rs: 52
    Round
    Main Review

    Description

    There is an integration bug in how PullFeedAccountData get_value is used the code calls get_value with clock.unix_timestamp.

    let price = price_feed.get_value(clock.unix_timestamp as u64, stalesness_threshold, 1, true)
    

    But the external library expect that argument to be clock_slot not a Unix timestamp.

    https://github.com/switchboard-xyz/solana-sdk/blob/dd1e768c5fd2bc99955c60629a2137dc08c4e81d/src/on_dema

    nd/accounts/pull_feed.rs#L369

    s.slot values are in slot units around ~381M on Solana mainnet, while clock.unix_timestamp is seconds since Unix epoch ~1.7B we have this check in the library.

    It filters submissions via s.slot >= clock_slot - max_staleness. Due to the bug, the filter check becomes

    s.slot >= (unix_timestamp_seconds) - (max_staleness)
    

    The code send max_staleness = 300 as default , so it will equals ~ 381_194_828 >= ~ 1_763_589_000 - 300. That will always be false, no submission ever passes the filter, submissions.len() == 0. So the later submissions.len() < min_samples will always revert with "NotEnoughSamples". On every user mint / redeem call, parse_oracles is being called.

    Which collect() all the prices from Pyth, Doves and SwitchboardOnDemand oracles, save them into Result<Vec<OraclePrice>>. If every oracle returns Ok(price), the code take minimum oracle price of that vector and proceed to the min() with the 1:1 path in mint/redeem. But if any oracle returned Err() for any reason the whole mint/redeem tx reverts.

    When we collect() an iterator of Result<T, E>, we get Result<Vec<T>, E>, This works as build a vector of all the Ok values, but if you see a single Err, stop and return that Err instead All users mint / redeem operations on vaults which have a SwitchboardOnDemand oracle will revert.

    After DoS, a vault manager can set the Switchboard oracle to None via update_oracle. But this will still cause blocking the usage of Switchboard until the code be mitigated, since the current integration is broken.

    Recommendation

    Use clock.slot instead of clock.unix_timestamp in get_value.

    Resolution

    Jupiter Team: Resolved.

  2. L-02 Low SetStatus(Disabled) Requires A Valid Oracle Unexpected Behavior Resolved
    Location
    vault.rs: 174
    Round
    Main Review

    Description

    In manage_vault handler in the SetStatus action, the code always checks whether the vault has at least one oracle configured (at least one oracle entry is non-empty) and reverts with a NoValidOracle error if all oracle slots are empty.

    This check runs regardless of the target status, so it also blocks SetStatus(Disabled) on a vault with no valid oracles. By contrast, the dedicated Disable action (requiring the VaultDisabler role) does not perform this oracle check and can successfully disable such a vault.

    This creates an inconsistency in how disabling works - a VaultManager using SetStatus(Disabled) may be unable to disable a misconfigured vault that a VaultDisabler can disable.

    As a result, an operator with only this VaultManager role cannot call SetStatus(Disabled) on a vault that currently has no valid oracles, even though disabling such a vault might be a reasonable emergency action, leading to confusing expectations about which role is responsible for safely disabling vaults.

    Recommendation

    Only require a valid oracle when enabling the vault. In manage_vault instruction allow SetStatus(Disabled) action to proceed regardless of oracle state.

    Resolution

    Jupiter Team: Resolved.

  3. L-03 Low Last Admin Removal Allow Permanent Protocol Halt Validation Resolved
    Location
    config.rs: 63-69
    Round
    Main Review

    Description

    In Config::remove_admin. There is no check that at least one admin remains, or prevents an admin from removing themselves.

    psm::manage_config lets an admin pause the protocol and then remove all admins including themselves, leaving is_paused == true and zero admins.

    Since manage_config requires an existing admin to run, there is no on chain way to unpause after this, and the protocol ( including all redemptions ) is permanently blocked.

    That's an info issue, but add a check as precaution

    Recommendation

    Disallow removing the last admin.

    require!(self.num_admins() > 1, PSmError::NotAllowed);
    

    Resolution

    Jupiter Team: Resolved.

  4. L-04 Low Lack Of PegManager Access Check Access Control Resolved
    Location
    admin.rs: 86
    Round
    Main Review

    Description

    The protocol defines a dedicated PegManager role, intended to be used in ConfigManagementAction::SetPegPriceUSD.

    There is a missed operator.is(OperatorRole::PegManager)?; in the code.

    SetPegPriceUSD is guarded by Admin, and the intention is that it has its dedicated role, as confirmed in the meeting call.

    Currently, the protocol cannot delegate peg operations to a specialized multisig without giving it full admin privileges

    Recommendation

    Implement the PegManager access check.

    Resolution

    Jupiter Team: Resolved.

  5. L-05 Low Restricted Custodian Deposits/Withdrawals Logical Error Resolved
    Location
    custodian.rs: 33
    Round
    Main Review

    Description

    The program’s data model separates the token program for:

    • the LP mint (stored in Config.token_program),
    • the vault/collateral mint (stored in Vault.token_program).

    Both Mint and Redeem instructions respect this split, check if config.token_program == lp_token_program.key() and vault.token_program == vault_token_program.key() and uses lp_token_program for LP and vault_token_program for collateral operations.

    However, the Deposit and Withdraw instructions enforce that both the LP token program and the vault token program are the same as the single token_program account passed in.

    These instructions only move collateral and never touch the LP mint, so the config.token_program equality is irrelevant and conflicts with the design that allows LP and collateral to live on different token program IDs (e.g. LP on Token‑2022, collateral on SPL).

    Consequently, if LP and collateral use different token program IDs (which Mint and Redeem allow), Deposit and Withdraw will revert with InvalidTokenProgram error.

    Recommendation

    Remove the config.token_program == token_program.key() constraint from both Deposit and Withdraw so they only validate and use vault.token_program, allowing LP and collateral to use different token program IDs.

    Optionally, if the LP and collateral are always intended to share the same token program, enforce that consistently.

    Resolution

    Jupiter Team: Resolved.

  6. L-06 Low Majority Of Oracles Instead Of All Oracle Acknowledged
    Location
    parse_oracles()
    Round
    Main Review

    Description

    As described in the SwitchboardOnDemand issue, the code collect() all the prices from Pyth, Doves, and SwitchboardOnDemand oracles, and saves them into Result<Vec<OraclePrice>>.

    If every oracle returns Ok(price), the code takes the minimum oracle price of that vector and proceeds. But if any oracle returns Err() for any reason, the whole mint/redeem tx reverts.

    Despite that the 3 oracles will be working correctly, to avoid temporary DoS in some cases, we could check if at least 2 succeed

    Recommendation

    For example ethena does

    if (validOracleCount < minNumberOfOracles) {
    // revert
    }
    

    After filtering, they count validOracleCount. If validOracleCount < minNumberOfOracles, they revert.

    Important: If you implemented this change make sure to not mask the other critical errors, the code enforce that all configured oracle accounts are present and match the vault config (owner + pubkey ) only treat genuine soft failures ( stale price, internal oracle error ) as skippable, and only if we have reached the minNumberOfOracles.

    Resolution

    Jupiter Team: Acknowledged.

  7. L-07 Low Switchboard Max_staleness Uses Seconds Not Slots Oracle Resolved
    Location
    oracle.rs: 52
    Round
    Main Review

    Description

    Other than clock.unix_timestamp, the code also passes the threshold differently. The vault stalesness_threshold is defined and used as a time in seconds for Pyth and Doves, but it is passed directly as max_staleness into PullFeedAccountData::get_value for Switchboard:

    get_value is implemented to treat max_staleness as a slot count, not seconds.

    This becomes ~300 seconds for Pyth/Doves but ~300 slots (~2–3 minutes) for Switchboard, leading to inconsistent freshness behavior across oracles.

    This can reject Switchboard prices more aggressively than configured, increasing the chance of NotEnoughSamples for Switchboard while Pyth/Doves still pass

    Recommendation

    Keep stalesness_threshold as a seconds parameter for Pyth/Doves, but convert it to slots before calling Switchboard

    Resolution

    Jupiter Team: Resolved.

  8. I-01 Informational Needlessly Public OraclePrice Functions Best Practices Resolved
    Location
    oracle.rs: 16
    Round
    Main Review

    Description

    I'd say from_pyth_v2() and its counterparts in oracle.rs should be private instead of public because they have minimal internal validation (looking at the use of AccountsInfo with no manual validation in particular) and are meant to only be used by parse_oracles() (which does indeed validate account owners, etc).

    This impacts nothing right now because they aren't used anywhere else but in parse_oracles() but changing this would prevent somebody mistakenly using them directly in the future.

    Recommendation

    Make from_pyth_v2() and its counterparts private instead of public.

    Resolution

    Jupiter Team: Resolved.

  9. I-02 Informational Enabled Vault Can Be Left With Zero Oracles Unexpected Behavior Resolved
    Location
    vault.rs: 178
    Round
    Main Review

    Description

    The protocol correctly blocks enabling a vault that has no valid oracle (for the SetStatus(Enabled) action it requires at least one non-empty oracle). However, once a vault is Enabled, an operator can call UpdateOracle and set each oracle slot to None (EmptyOracle) without any guard.

    This allows the vault to remain Enabled while having zero configured oracles. In that state, user instructions (mint/redeem) will fail at oracle validation, resulting in an enabled vault that unexpectedly cannot process user flows, confusing integrations and operators.

    Recommendation

    Consider rejecting updates that would remove the last oracle while vault is enabled or set the vault’s status to Disabled during that case.

    Resolution

    Jupiter Team: Resolved.

  10. I-03 Informational From_pyth_v2() Can DOS Minting And Redeeming DoS Resolved
    Location
    oracle.rs: 17-40
    Round
    Main Review

    Description

    Look at the (price_u64 - price.conf).into(), snippet of the from_pyth_v2() function. Pyth only guarantees conf to be "positive when present" but not smaller than the price.

    Albeit highly unlikely, it is possible that stablecoins become distressed (TerraLuna, USDC/DAI depeg after SVG). Therefore a scenario where conf is bigger or equal to price is also unlikely but possible.

    In the case where this underflows, because you've set profile.release to have overflow-checks = true, this will panic. It's not caught within parse_oracles() and parse_oracles() is always called with the ? operator. This means the panic will propagate and revert the transaction even if all of the other oracles returned valid prices, i.e.: a DoS in mint() and redeem().

    What's more, this line can also return 0 if price == conf. Returning 0 is unexpected and something that is clearly not wanted (since we've checked that the price is not 0 or negative in this function and also for Switchboard we use only_positive=true).

    There is a layer of defense for this situation: the vault min-max limits, but there's no guarantee that they will be sensible (even though the defaults are sensible at $0.5-$1, these can be eventually set to anything).

    Now, parse_oracles() returns the smallest given price, so it will return the 0 if it's provided by from_pyth_v2() even if there are other valid prices from the other oracles. And if the vault min-max limits don't catch it, this would trickle down to compute_mint_amount and make it return 0 as mint_amount which would make require!(mint_amount > 0, JupStableError::ZeroAmount); throw.

    On the redeeming side, an oracle price of 0 would trickle down to cause a "division by 0" panic in calculate_redeem_amount.

    Recommendation

    It would be better to check something like price.checked_sub(conf) <= 0 rather then the price.price <= 0 check we have and the price_u64 - price.conf we do checked_sub returns none if there's an overflow instead of panicking.

    Resolution

    Jupiter Team: Resolved.

  11. I-04 Informational Missing Decimal Check At Pool Creation Validation Resolved
    Location
    pool.rs: 71
    Round
    Main Review

    Description

    Because of require!(diff <= 19, PSmError::MathOverflow); normalize_amount always assumes that

    |decimals - target_decimals| <= 19
    

    But that is never enforced anywhere when the pool is created The program allows a pool to be created between any two mints, including ones whose decimals differ by 20+.

    And when a user later calls redeem, normalize_amount will see diff > 19 and revert with PSmError::MathOverflow before any transfers happen.

    So a pool can be configured and look valid on-chain, admins can supply and withdraw, but no user will ever be able to redeem if the decimals gap is > 19.

    Recommendation

    Enforce the same require check at pool creation for consistency

    Resolution

    Jupiter Team: Resolved.

  12. I-05 Informational Admins Can't Clear Roles Once Set Informational Resolved
    Location
    operator.rs: 78
    Round
    Main Review

    Description

    The implementation for Operator

    (a Guardian proof of concept 854f3f00e5c8f4c587aa1b7/programs/jup-stable/src/state/operator.rs#L75)

    contains both set_role and clear_role.

    The set_role function is use in manage_operator

    (a Guardian proof of concept 854f3f00e5c8f4c587aa1b7/programs/jup-stable/src/instructions/operator.rs#L78),

    but clear_role is used nowhere (not within manage_operator nor another function.

    This means the admin cannot clear a role once given.

    P.S.: If a role owner becomes malevolent you can still disable them from being an operator, so this is not the worst bug in the world.

    Recommendation

    Implement clear_role() as part of manage_role() similar to how it was done for set_role() or potentially add another program callable function similar to delete_benefactor().

    Resolution

    Jupiter Team: Resolved.

  13. I-06 Informational Vault.bump Field Is Never Initialized Best Practices Resolved
    Location
    vault.rs: 96
    Round
    Main Review

    Description

    The Vault account includes a bump field, but create_vault does not set it and it remains at its default value 0. That makes the field effectively unused and can cause maintenance issues if later code assumes it contains the correct bump for vault PDA derivations.

    Recommendation

    Either initialize vault.bump in create_vault using ctx.bumps.vault or remove the field entirely.

    Resolution

    Jupiter Team: Resolved.

  14. I-07 Informational Unsafe Downcasting Math Acknowledged
    Location
    state/benefactor.rs
    Round
    Main Review

    Description

    The following functions perform downcasting from u128 to u64 using the as keyword, which omits arithmetic overflow checks:

    pub fn calculate_mint_fee(&self, amount: u64) -> u64 {
    (amount as u128 * self.mint_fee_rate as u128 / 10000) as u64 + 1
    }
    pub fn calculate_redeem_fee(&self, amount: u64) -> u64 {
    (amount as u128 * self.redeem_fee_rate as u128 / 10000) as u64 + 1
    }
    

    Recommendation

    It is recommended to use try_into() instead of the as keyword.

    Resolution

    Jupiter Team: Acknowledged.

Remediation Review

4 findings
  1. L-01 Low Missing Token_program In Custodian ATA Compatibility Resolved
    Location
    user.rs: 59C6-61C23
    Round
    Remediation Review

    Description

    The vault can be configured to use either SPL Token or Token-2022 mints, but in the Mint and Withdraw contexts, custodian_token_account is constrained using only associated_token::authority and associated_token::mint and does not specify associated_token::token_program, so Anchor derives and validates the ATA using the default SPL Token program.

    This produces a different address than the Token-2022 ATA and would make mints and privileged withdrawals fail for Token-2022 vaults.

    Recommendation

    Include associated_token::token_program in the custodian_token_account constraints so ATA derivation works for both SPL Token and Token-2022.

    Resolution

    Jupiter Team: The issue was resolved in commit 57c2a28.

  2. L-02 Low Redeem Blocks Exits During Depegs Oracle Resolved
    Location
    vault.rs: 189
    Round
    Remediation Review

    Description

    In the Oracle Price Validation logic during Redemptions, There is an important difference between the solana and the ethena implementation when the collateral asset depegs (crashes or it's price become low)

    In the EVM version, the oracle price validation logic is split based on the operation type, Mint vs Redeem.

    But in the solana version, the redeem instruction calls vault.validate_oracle_price, which enforces both minimum and maximum price bounds regardless of the operation context.

    If the collateral asset depegs and price drops below min_oracle_price_usd, the validate_oracle_price function will fail. This blocks the redeem instruction.

    So during a market crash or depeg event, when users most need to redeem their stablecoins for collateral, users are locked in and cannot exit until the admin manually lowers the min_oracle_price_usd.

    If the project intends to use only low volatility stablecoins like USDT and USDC, then it would be safe. But if you intend to allow other high volatility stablecoins as collateral

    Recommendation

    Consider updating validate_oracle_price to accept an operation context ( is_mint: bool ) similar to ethena.

    Minting: they verify price >= min_oracle_price Redeeming: they verify price <= max_oracle_price

    Resolution

    Jupiter Team: The issue was resolved in commit 16aa229.

  3. I-01 Informational Custody Separation Allows Redemption Siphon Informational Acknowledged
    Location
    vault.rs: 89-90
    Round
    Remediation Review

    Description

    After this update we had to implement to satisfy Ethena. They require that funds be sent directly to the custodian upon minting. When needed, they will withdraw funds from the custodian to the vault token account to allow redemption.

    now we have two different pools for the same collateral

    Mint sends collateral to the custodian ATA and not the vault

    Redeem pays collateral out of the vault token account

    This allows the following scenario, a user can watch for Ethena top‑ups,

    Assume vault_token_account currently has 20k USDC topped up for redemptions

    User has 5k USDC

    He use 5k USDC to mint the stablecoin → custodian gets the 5k , user gets 5k LP

    Then redeem the 5k LP → user gets 5k USDC from vault_token_account

    Repeat again using his same 5k until leaving the vault permanently dry, consuming all the 20k forcing Ethena into a operational treadmill ("top up → gets drained → top up again… so on")

    This didn't exist In the previous design because, Mint was depositing into the same pool that redeem withdraws from, so a mint, redeem leaves the pool unchanged

    Recommendation

    Take care of enforcing a strict period limit for this case

    Resolution

    Jupiter Team: Acknowledged.

  4. I-02 Informational Unused Mint Parameter In PDA Binding Informational Resolved
    Location
    config.rs: 18
    Round
    Remediation Review

    Description

    config_seeds! macro accepts a mint argument but does not include it in the seeds

    macro_rules! config_seeds {
    (\$mint:expr, \$bump:expr) => {
    &[CONFIG_PREFIX, &[\$bump]]
    

    This means the config pda is not bound to a specific mint

    It’s derived from only the constant prefix config and the bump

    Today we only have one config, so the signature and presence of Config.mint: Pubkey indicates that this was a past intention to make the system per mint config

    Recommendation

    Remove the parameter because it's currently unused

    Resolution

    Jupiter Team: The issue was resolved in commit 83da261.

More from Jupiter

  1. Yield Store

    47 findings1 critical · 6 high 47 findings: 1 critical, 6 high, 16 medium, 12 low, 12 informational
  2. Offerbook

    36 findings 36 findings: 4 medium, 15 low, 17 informational
  3. JupUSD Updates

    11 findings 11 findings: 11 informational

Put your code through the same review.

This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.

Get a quote