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
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-
L-01 Low Slot-timestamp Mismatch Breaks Price Fetching DoS Resolved
Description
There is an integration bug in how
PullFeedAccountData get_valueis used the code calls get_value withclock.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_slotnot a Unix timestamp.s.slotvalues are in slot units around ~381M on Solana mainnet, whileclock.unix_timestampis 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 becomess.slot >= (unix_timestamp_seconds) - (max_staleness)The code send
max_staleness = 300as 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 latersubmissions.len() <min_sampleswill always revert with "NotEnoughSamples". On every user mint / redeem call,parse_oraclesis being called.Which
collect()all the prices from Pyth, Doves andSwitchboardOnDemandoracles, save them intoResult<Vec<OraclePrice>>. If every oracle returnsOk(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 returnedErr()for any reason the whole mint/redeem tx reverts.When we
collect()an iterator ofResult<T, E>, we getResult<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 aSwitchboardOnDemandoracle 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.slotinstead ofclock.unix_timestampinget_value.Resolution
Jupiter Team: Resolved.
-
L-02 Low SetStatus(Disabled) Requires A Valid Oracle Unexpected Behavior Resolved
Description
In
manage_vaulthandler in theSetStatusaction, the code always checks whether the vault has at least one oracle configured (at least one oracle entry is non-empty) and reverts with aNoValidOracleerror 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 dedicatedDisableaction (requiring theVaultDisablerrole) does not perform this oracle check and can successfully disable such a vault.This creates an inconsistency in how disabling works - a
VaultManagerusingSetStatus(Disabled)may be unable to disable a misconfigured vault that aVaultDisablercan disable.As a result, an operator with only this
VaultManagerrole cannot callSetStatus(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_vaultinstruction allowSetStatus(Disabled)action to proceed regardless of oracle state.Resolution
Jupiter Team: Resolved.
-
L-03 Low Last Admin Removal Allow Permanent Protocol Halt Validation Resolved
Description
In
Config::remove_admin. There is no check that at least one admin remains, or prevents an admin from removing themselves.psm::manage_configlets an admin pause the protocol and then remove all admins including themselves, leavingis_paused == trueand zero admins.Since
manage_configrequires 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.
-
L-04 Low Lack Of PegManager Access Check Access Control Resolved
Description
The protocol defines a dedicated
PegManagerrole, intended to be used inConfigManagementAction::SetPegPriceUSD.There is a missed
operator.is(OperatorRole::PegManager)?;in the code.SetPegPriceUSDis 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
PegManageraccess check.Resolution
Jupiter Team: Resolved.
-
L-05 Low Restricted Custodian Deposits/Withdrawals Logical Error Resolved
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()andvault.token_program == vault_token_program.key()and useslp_token_programfor LP andvault_token_programfor 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_programaccount passed in.These instructions only move collateral and never touch the LP mint, so the
config.token_programequality 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
InvalidTokenProgramerror.Recommendation
Remove the
config.token_program == token_program.key()constraint from both Deposit and Withdraw so they only validate and usevault.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.
- the LP mint (stored in
-
L-06 Low Majority Of Oracles Instead Of All Oracle Acknowledged
Description
As described in the
SwitchboardOnDemandissue, the codecollect()all the prices fromPyth,Doves, andSwitchboardOnDemandoracles, and saves them intoResult<Vec<OraclePrice>>.If every oracle returns
Ok(price), the code takes the minimum oracle price of that vector and proceeds. But if any oracle returnsErr()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. IfvalidOracleCount < 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.
-
L-07 Low Switchboard Max_staleness Uses Seconds Not Slots Oracle Resolved
Description
Other than
clock.unix_timestamp, the code also passes the threshold differently. The vaultstalesness_thresholdis defined and used as a time in seconds for Pyth and Doves, but it is passed directly asmax_stalenessintoPullFeedAccountData::get_valuefor Switchboard:get_valueis implemented to treatmax_stalenessas 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
NotEnoughSamplesfor Switchboard while Pyth/Doves still passRecommendation
Keep
stalesness_thresholdas a seconds parameter for Pyth/Doves, but convert it to slots before calling SwitchboardResolution
Jupiter Team: Resolved.
-
I-01 Informational Needlessly Public OraclePrice Functions Best Practices Resolved
Description
I'd say
from_pyth_v2()and its counterparts inoracle.rsshould be private instead of public because they have minimal internal validation (looking at the use ofAccountsInfowith no manual validation in particular) and are meant to only be used byparse_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.
-
I-02 Informational Enabled Vault Can Be Left With Zero Oracles Unexpected Behavior Resolved
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 isEnabled, an operator can callUpdateOracleand set each oracle slot to None (EmptyOracle) without any guard.This allows the vault to remain
Enabledwhile 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
Disabledduring that case.Resolution
Jupiter Team: Resolved.
-
I-03 Informational From_pyth_v2() Can DOS Minting And Redeeming DoS Resolved
Description
Look at the
(price_u64 - price.conf).into(),snippet of thefrom_pyth_v2()function. Pyth only guaranteesconfto 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 whereconfis bigger or equal topriceis 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 withinparse_oracles()andparse_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 inmint()andredeem().What's more, this line can also return
0ifprice == conf. Returning0is 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 useonly_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 the0if it's provided byfrom_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 tocompute_mint_amountand make it return0asmint_amountwhich would makerequire!(mint_amount > 0, JupStableError::ZeroAmount);throw.On the redeeming side, an oracle price of
0would trickle down to cause a "division by 0" panic incalculate_redeem_amount.Recommendation
It would be better to check something like
price.checked_sub(conf) <= 0rather then theprice.price <= 0check we have and theprice_u64 - price.confwe dochecked_subreturns none if there's an overflow instead of panicking.Resolution
Jupiter Team: Resolved.
-
I-04 Informational Missing Decimal Check At Pool Creation Validation Resolved
Description
Because of
require!(diff <= 19, PSmError::MathOverflow); normalize_amountalways assumes that|decimals - target_decimals| <= 19But 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_amountwill seediff > 19and revert withPSmError::MathOverflowbefore any transfers happen.So a pool can be configured and look valid on-chain, admins can
supplyandwithdraw, 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.
-
I-05 Informational Admins Can't Clear Roles Once Set Informational Resolved
Description
The implementation for Operator
(a Guardian proof of concept 854f3f00e5c8f4c587aa1b7/programs/jup-stable/src/state/operator.rs#L75)
contains both
set_roleandclear_role.The
set_rolefunction is use inmanage_operator(a Guardian proof of concept 854f3f00e5c8f4c587aa1b7/programs/jup-stable/src/instructions/operator.rs#L78),
but
clear_roleis used nowhere (not withinmanage_operatornor 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 ofmanage_role()similar to how it was done forset_role()or potentially add another program callable function similar todelete_benefactor().Resolution
Jupiter Team: Resolved.
-
I-06 Informational Vault.bump Field Is Never Initialized Best Practices Resolved
Description
The Vault account includes a
bumpfield, butcreate_vaultdoes 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.bumpincreate_vaultusingctx.bumps.vaultor remove the field entirely.Resolution
Jupiter Team: Resolved.
-
I-07 Informational Unsafe Downcasting Math Acknowledged
Description
The following functions perform downcasting from
u128tou64using theaskeyword, 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 theaskeyword.Resolution
Jupiter Team: Acknowledged.
Remediation Review
4 findings-
L-01 Low Missing Token_program In Custodian ATA Compatibility Resolved
Description
The vault can be configured to use either SPL Token or Token-2022 mints, but in the Mint and Withdraw contexts,
custodian_token_accountis constrained using onlyassociated_token::authorityandassociated_token::mintand does not specifyassociated_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_programin thecustodian_token_accountconstraints so ATA derivation works for both SPL Token and Token-2022.Resolution
Jupiter Team: The issue was resolved in commit 57c2a28.
-
L-02 Low Redeem Blocks Exits During Depegs Oracle Resolved
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
redeeminstruction callsvault.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, thevalidate_oracle_pricefunction will fail. This blocks theredeeminstruction.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_priceto accept an operation context (is_mint: bool) similar to ethena.Minting: they verify
price >= min_oracle_priceRedeeming: they verifyprice <= max_oracle_priceResolution
Jupiter Team: The issue was resolved in commit 16aa229.
-
I-01 Informational Custody Separation Allows Redemption Siphon Informational Acknowledged
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_accountcurrently has 20k USDC topped up for redemptionsUser 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_accountRepeat 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.
-
I-02 Informational Unused Mint Parameter In PDA Binding Informational Resolved
Description
config_seeds!macro accepts a mint argument but does not include it in the seedsmacro_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
configand the bumpToday we only have one config, so the signature and presence of
Config.mint: Pubkeyindicates that this was a past intention to make the system per mint configRecommendation
Remove the parameter because it's currently unused
Resolution
Jupiter Team: The issue was resolved in commit 83da261.
No findings match.
More from Jupiter
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.
