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

Security review · August 2026

Offerbook

for Jupiter

Guardian's review of Offerbook for Jupiter, published August 2026. The report records 36 findings across 2 review rounds, including 4 medium and 15 low.

Published
Review window
June 22 to July 24, 2026
Rounds
Main Review, Remediation Review
Language
Rust
Chains
Solana
Sector
DEXs and AMMs
  • 0 Critical
  • 0 High
  • 4 Medium
  • 15 Low
  • 17 Informational

36 acknowledged

Scope

43 files in scope · 6,691 nSLOC
FilenSLOCLines
.../fill_non_fungible_principal_offer.rs742857
.../fill_non_fungible_collateral_offer.rs606699
.../repay_non_fungible_loan.rs545628
.../claim_non_fungible_loan.rs396454
.../fill_token_principal_offer.rs366435
.../fill_token_collateral_offer.rs361432
programs/offerbook/src/events.rs351392
.../repay_token_loan.rs268313
.../claim_token_loan.rs224261
programs/offerbook/src/state/offer.rs204253
programs/offerbook/src/utils.rs189240
.../create_token_principal_offer.rs177238
.../create_token_collateral_offer.rs176239
.../create_non_fungible_principal_offer.rs176232
programs/offerbook/src/state/config.rs175223
programs/offerbook/src/lib.rs167203
.../create_non_fungible_collateral_offer.rs153208
.../escrow_programmable_nft_withdraw.rs148174
.../escrow_programmable_nft_deposit.rs145168
programs/offerbook/src/state/loan.rs108140
.../escrow_classic_nft_deposit.rs101118
programs/offerbook/src/state/asset.rs93113
.../escrow_classic_nft_withdraw.rs8699
programs/offerbook/src/instructions/update_config.rs7185
.../escrow_core_nft_withdraw.rs7086
.../escrow_token_deposit.rs6879
.../escrow_core_nft_deposit.rs6884
programs/offerbook/src/state/user.rs6887
.../escrow_token_withdraw.rs6779
programs/offerbook/src/instructions/claim_fee.rs5872
programs/offerbook/src/instructions/create_user.rs5566
programs/offerbook/src/error.rs4647
programs/offerbook/src/instructions/init.rs3848
programs/offerbook/Cargo.toml2734
programs/offerbook/src/instructions/cancel_offer.rs2532
.../non_fungible/mod.rs1820
.../fungible/mod.rs1617
programs/offerbook/src/instructions/mod.rs1415
programs/offerbook/src/state/mod.rs1011
.../classic/mod.rs45
.../mpl_core/mod.rs45
.../programmable/mod.rs45
programs/offerbook/src/constants.rs34

Findings 36

Main Review

30 findings · June 22 to July 8, 2026
  1. M-01 Medium Token Delegates Can Block Collateral Transfer Warning Acknowledged
    Location
    utils.rs:18
    Round
    Main Review

    Description

    Token offer creation validates Token-2022 mints through validate_mint_extensions(), but that helper only rejects nonzero transfer fees and configured transfer hooks. It does not reject the PermanentDelegate extension, and it also allows collateral mints with other external control surfaces such as a configured freeze authority or the Token-2022 Pausable extension.

    For token-collateral loans, collateral is moved into a loan vault whose token owner is the loan PDA. The protocol later assumes that only the loan PDA can move those tokens during repayment or default claim. With a Token-2022 collateral mint that has a permanent delegate, the mint-level delegate can transfer or burn tokens from any token account for that mint, including the loan vault, without the loan PDA signing.

    Example: a lender accepts or creates an offer against Token-2022 collateral that has a permanent delegate controlled by the borrower or a cooperating party. After the collateral is locked in the loan vault, the delegate drains or burns the vault balance. The lender's later default claim can receive less collateral or fail while trying to transfer the liquidation fee.

    Recommendation

    Reject Token-2022 collateral mints with PermanentDelegate configured. Also reject mints with retained freeze authority or Pausable control unless the protocol explicitly intends to trust that authority.

  2. M-02 Medium Token-2022 mint mutability bypass Logical Error Acknowledged
    Location
    programs/offerbook/src/utils.rs:18-47
    Round
    Main Review

    Description

    Token-2022 mint extensions are validated only when an offer is created, but the program does not reject mutable extension authorities and does not revalidate the mint during fill, repayment, or claim flows. Because the authority fields are not checked, a Token-2022 mint that is safe at offer creation can later be updated to enable transfer fees or a transfer hook. This can cause loan collateral transfers, repayments, collateral returns, or default claims to transfer less than the protocol expects or to revert entirely.

    Recommendation

    Reject Token-2022 mints whose transfer-fee or transfer-hook authorities are set. Require transfer_fee_config_authority to be unset when TransferFeeConfig is present, and require authority to be unset when TransferHook is present.

  3. M-03 Medium Unsafe Token-2022 NFT Collateral Logical Error Acknowledged
    Location
    programs/offerbook/src/instructions/non_fungible/fill_non_fungible_collateral_offer.rs:163-168
    Round
    Main Review

    Description

    NFT collateral can be supplied across three standards (classic, programmable, and MPL Core). Broad Collection or FirstVerifiedCreator offers let the borrower select the concrete asset only at fill time. None of the fill paths validate that the chosen asset's transfer controls will still allow the loan PDA to move the collateral on a later default claim. This affects all three standards.

    Classic NFTs (Token-2022 extensions)

    Classic NFT flows accept NFT collateral mints through the generic token interface and only require the mint to look like an NFT by checking supply == 1 and decimals == 0.

        #[account(
            constraint = nft_mint.supply == 1 && nft_mint.decimals == 0 @ OfferbookError::InvalidTokenMint,
            token::token_program = token_program,
        )]
        pub nft_mint: Box<InterfaceAccount<'info, Mint>>,
    

    However, NFT collateral mints are not subject to any Token-2022 extension or mutable-authority validation before being accepted into a loan. As a result, a Token-2022 mint can be used as classic NFT collateral while retaining external transfer controls such as PermanentDelegate, freeze authority, pausable controls, transfer-hook authority, or transfer-fee authority. A malicious borrower can lock a Token-2022 NFT whose mint has a permanent delegate or mutable hook. Consequently, lenders may be unable to claim defaulted NFT collateral, or the collateral may be removed from the loan vault after being accepted.

    Programmable NFTs (authorization rule sets)

    Programmable NFT transfers are gated by Metaplex authorization rules. The fill path checks only the metadata collection or creator filter, then forwards the supplied authorization_rules account to the transfer CPI without verifying that the rule set will also permit the later loan-PDA-to-lender claim transfer. A borrower can fill a broad offer with a pNFT whose rule set allows the deposit into loan custody but rejects the claim transfer from the loan PDA to the lender. Because a rule set can be mutable, even a rule set that is permissive at fill time can later be changed by its authority after the pNFT is locked.

    Core NFTs (transfer-affecting plugins)

    MPL Core assets can carry plugins such as FreezeDelegate, PermanentFreezeDelegate, TransferDelegate, PermanentTransferDelegate, oracle plugins, and lifecycle hooks, and plugin authorities can be addresses outside the protocol. The fill path inspects only the collection account or the VerifiedCreators plugin, not transfer-affecting plugins. A borrower can fill a broad Core offer with an asset whose freeze or transfer plugin is controlled by an external authority, then freeze the asset or block transfers before default so the claim transfer fails even though the loan PDA owns the asset.

    Recommendation

    Validate NFT collateral before accepting it into a loan, for every standard:

    • Classic: validate classic NFT collateral mints before accepting them into a loan. Reject Token-2022 NFT collateral mints with PermanentDelegate, retained freeze authority, pausable controls, transfer hooks, transfer-fee authorities, or other mutable transfer-control extensions.
    • Programmable: require the rule set to be absent, or immutable and permissive for the loan-PDA-to-lender and loan-PDA-to-borrower transfer paths. A fill-time permissiveness check alone is insufficient because a mutable rule set can be changed after the pNFT is locked.
    • Core: reject assets carrying FreezeDelegate, PermanentFreezeDelegate, TransferDelegate, PermanentTransferDelegate, oracle, or lifecycle-hook plugins, or require that any freeze or transfer-affecting plugin authority be the loan PDA so neither the borrower nor a third party can alter transferability after fill.
  4. L-01 Low Referral Rewards Are Only Events Gaming Acknowledged
    Location
    utils.rs:118
    Round
    Main Review

    Description

    In every fee-bearing path the full computed protocol_fee is transferred to the fee authority token account, while the referral and referee reward splits are only emitted as events and never moved on-chain.

    Because the reward events are derived only from on-chain activity and self-dealing is not prevented across separate wallets, a user can manufacture reward events. The offer-creator check only blocks offer.creator == signer, so the same actor using two wallets (one registered with a referrer) can repeatedly fill and repay loans between the wallets to emit ReferralReward events crediting a colluding referrer.

    Recommendation

    Treat referral and referee reward events as untrusted hints. Settle rewards offchain using sybil and wash trade resistant logic rather than raw event amounts, or move reward accounting and payout onchain with explicit anti self-dealing constraints.

  5. L-02 Low Single-Step Admin Transfer Access Control Acknowledged
    Location
    programs/offerbook/src/state/config.rs:92-101
    Round
    Main Review

    Description

    The admin role can be transferred in a single transaction without requiring the new admin to accept the role. The current logic only checks that the new address is not the current admin and is not the default pubkey, then immediately overwrites the admin field.

        pub fn set_admin(&mut self, pubkey: &Pubkey) -> Result<()> {
            require!(!self.is_admin(pubkey), OfferbookError::NewAdminIsOldAdmin);
            require!(
                !pubkey.eq(&Pubkey::default()),
                OfferbookError::NotAuthorized
            );
    
            self.admin = *pubkey;
            Ok(())
        }
    

    If the current admin mistakenly submits an incorrect address administrative control may be permanently lost. Since the admin controls protocol configuration such as pausing, repayment settings, fee parameters, and risk limits, this can leave the protocol unable to react to emergencies or restore normal operation.

    Recommendation

    Consider implementing a two-step admin transfer flow. The current admin should nominate a pending admin, and only that nominated address should be able to separately accept this role.

  6. L-03 Low Fee Math Can Block Claims Math Acknowledged
    Location
    programs/offerbook/src/utils.rs:80-82
    Round
    Main Review

    Description

    The compute_protocol_fee helper performs multiplication in u64 before dividing by 10_000. This helper is reused for liquidation fees during token collateral default claims, where the fee base is the full collateral amount.

    rust
    fn compute_protocol_fee(interest: u64, protocol_fee_bps: u32) -> u64 {
        interest * u64::from(protocol_fee_bps) / 10_000
    }
    

    The program is compiled with overflow checks enabled, so if loan.collateral_amount * liquidation_fee_bps exceeds u64::MAX, the default claim aborts before collateral is transferred. Offer creation only checks that collateral is above the configured minimum, but it does not enforce an upper bound that keeps fee multiplication within range. This is especially relevant because the program does not implement an on-chain collateral whitelist. Any supported token mint can be used as collateral if the offer creator accepts it, including long-tail assets with very large supplies, high decimals, or unusual denominations. A borrower can therefore propose a loan using a high amount long-tail collateral token that makes the liquidation fee calculation overflow. If the loan defaults, the lender’s claim can consistently fail during fee calculation, leaving collateral stuck in the loan vault.

    Recommendation

    Perform fee calculations in u128 and only downcast after division.

  7. L-04 Low Counter offers can be sniped Frontrunning Acknowledged
    Location
    fill_token_principal_offer.rs, fill_token_collateral_offer.rs, fill_non_fungible_principal_offer, fill_non_fungible_collateral_offer.rs
    Round
    Main Review

    Description

    Counter offers are not restricted to the creator of the offer being countered. When a user creates a counter offer, the program stores the referenced offer in countered_offer, but later fill instructions do not require the filler to be the creator of that referenced offer. They only require the selected counter offer to be active, unexpired, and not filled by its own creator.

    This means a third party can fill a counter offer before the intended original offer owner accepts it. For example, borrower A posts an offer, lender B creates a more attractive counter offer that points to A's offer, and borrower C sees B's counter in the mempool or indexer. If C can satisfy B's collateral terms, C can fill B's counter offer first. B receives the exact published loan terms, but A loses the intended counter-offer opportunity.

    The root cause is that countered_offer is only a matching hint for clients and indexers. It is not used as an on-chain authorization target during fill.

    Recommendation

    If counter offers are intended to be exclusive to the referenced offer creator, add a dedicated accept-counter path or fill constraint that requires the filler to match the creator of the referenced countered_offer. That path should also define whether the referenced original offer and sibling counters are consumed, cancelled, or left live.

    If counter offers are intended to be public offers with a UI link, update client and documentation language to make that explicit and avoid presenting them as exclusive to the original offer owner.

  8. L-05 Low Partial fill rounding undercollateralizes loans Rounding Acknowledged
    Location
    fill_token_principal_offer.rs:218, fill_token_collateral_offer.rs:220
    Round
    Main Review

    Description

    For partial-fillable fungible offers, the paired-side amount is computed with floor (integer) division against the offer's original totals, and Offer::take_loan marks the offer Fulfilled as soon as either side reaches zero. Each fill therefore drops up to one raw unit of the paired asset, and the abandoned remainder is never collected.

    On a principal offer the borrower posts the pro-rata collateral:

    // fill_token_principal_offer.rs::compute_collateral_amount
    (u128::from(offer.collateral_amount) * u128::from(self.principal_fill_amount)
        / u128::from(offer.principal_amount))
    .try_into()?
    

    The symmetric principal computation on the collateral-offer path has the same shape. The floor means sum(floor(collateral_amount * pᵢ / principal_amount)) <= collateral_amount, so once all principal is drawn the lender has received slightly less collateral than the advertised ratio demanded. The dropped fractions are made permanent because the offer closes when the principal side hits zero, leaving the leftover remaining_collateral capacity unenforced.

    Impact: Under default configuration this is dust. Each fill must lock more than min_collateral_amount (and more than min_principal_amount on the other path), which bounds the total relative undercollateralization to 1 / min_collateral_amount.

    With the default min_collateral_amount = 10_000 the worst case is < 0.01%, which is economically negligible. The issue only becomes material if an admin lowers min_collateral_amount (or min_principal_amount) toward single digits, which simultaneously defeats the dust guard those parameters exist to provide. set_min_collateral_amount only enforces > 0, so a misconfiguration to 1 or 2 is permitted and would allow the relative loss to approach significant levels.

    Recommendation

    This is defense-in-depth; no change is required under sane configuration.

    • Keep min_principal_amount and min_collateral_amount set high in the collateral token's real decimals in production
    • Consider enforcing a sane lower bound in set_min_collateral_amount / set_min_principal_amount beyond > 0.
  9. L-06 Low Core collection filter skips membership check Warning Acknowledged
    Location
    fill_non_fungible_principal_offer.rs:418
    Round
    Main Review

    Description

    NFT principal offers can use a broad Collection filter, and the concrete collateral is resolved at fill time. For MPL Core collateral, the filter check only compares the pubkey of the separately supplied collection account to the filter value. It never reads the locked asset's own collection membership from the BaseAssetV1 account.

    get_collection returns the key of the passed collection account, and validate_filter compares that key to collection_filter.collection:

    fn get_collection(&self) -> Result<Option<Pubkey>> {
        Ok(self.collection.as_ref().map(|c| c.key()))
    }
    // AssetFilter::Collection(collection_filter) =>
    require!(collection == Some(collection_filter.collection), OfferbookError::InvalidCollateral);
    

    So the on-chain filter passes whenever the borrower supplies the real filtered collection account, regardless of whether the asset they are locking actually belongs to that collection. This differs from the other paths that bind to the asset being locked: classic and pNFT bind nft_metadata to the locked mint via Metadata::find_pda and require a verified collection, and the Core FirstVerifiedCreator branch reads the VerifiedCreators plugin directly off the asset.

    What currently prevents a mismatch is external, not this check: lock_nft invokes the MPL Core TransferV1 with both asset and collection, and MPL Core rejects the transfer if the asset's update authority is not that collection. The program's own filter validation is insufficient on its own and relies entirely on the lock CPI to enforce membership.

    Recommendation

    In the Core Collection branch of validate_filter, read the update authority from the BaseAssetV1 asset account and require it equals UpdateAuthority::Collection(collection_filter.collection), mirroring how the FirstVerifiedCreator branch reads the plugin off the asset.

  10. L-07 Low FVC filter trusts creator key Warning Acknowledged
    Location
    fill_non_fungible_principal_offer.rs:435
    Round
    Main Review

    Description

    Principal side NFT offers can be scoped with a FirstVerifiedCreator (FVC) filter. For these broad offers, creation stores Asset::None plus a creator key instead of a concrete collateral asset or collection. At fill time, the borrower chooses the concrete NFT, and the filter check only requires the NFT's first verified creator to match the stored creator key.

    The Metaplex path compares metadata_first_verified_creator(metadata) to the filter creator. The MPL Core path performs the same kind of single-key comparison after reading the VerifiedCreators plugin from the asset.

    This means an FVC offer trusts the named creator key as the collateral universe. A creator key can verify NFTs across multiple collections or future drops, and all of those assets can satisfy the same FVC offer even if only one collection is valuable. For example:

    • Alice offers to lend 1000 USDC against any NFT whose first verified creator is Bob.
    • Bob or another holder can fill the offer with a low value NFT from a different Bob-verified drop.
    • If the loan defaults, Alice receives that low value NFT.

    The risk is that FVC can be mistaken for collection-level collateral selection, while it actually relies on the named creator not to verify low-value sibling assets.

    Recommendation

    Disable FVC for value sensitive lending, or clearly label it as creator-trusted collateral selection. For stronger filtering, require broad NFT offers to use a verified collection, a protocol or lender allowlist of acceptable collections, or a combined "collection plus creator" filter.

  11. L-08 Low Classic NFT claim destination is UI-trusted Validation Acknowledged
    Location
    claim_non_fungible_loan.rs:89
    Round
    Main Review

    Description

    The classic NFT default-claim path accepts lender_collateral_escrow as a remaining account and only constrains it to be a token account for the NFT mint and token program. It does not require the destination token account to be owned by the lender, the lender's user PDA, or the lender's associated token account.

    The transfer then moves the claimed NFT from the loan vault to that supplied token account, and the emitted EscrowDeposit event records user: signer. For the happy path, tests pass the lender's ATA. However, a wallet, SDK, or route builder that supplies a different token account for the same mint can direct the classic NFT collateral somewhere else while the transaction still looks like the lender's claim.

    The other paths are not exposed to this. The fungible claim path is stricter and pins the destination to the lender user's escrow ATA via associated_token::authority = signer_user. Core NFT claims set new_owner directly to the lender signer and use no token account, so there is no client-supplied destination to redirect. Programmable NFT claims do pass an equally loose lender_nft_token_account (constrained only by mint and token program), but the Token Metadata TransferV1 CPI fixes destination_owner to the lender signer and binds the destination token account to that owner's associated token account, so the supplied account cannot point elsewhere. The classic path is the lone outlier because it performs a raw SPL transfer_checked to the supplied account with no owner binding.

    Recommendation

    Require the classic NFT claim destination to be the lender's canonical ATA, or at least require token::authority = signer on the destination token account. If arbitrary destinations are intentionally supported, client builders should always derive and display the exact destination owner and token account before signature.

  12. L-09 Low Classic NFT Withdraw Uses Readonly ATA Unexpected Behavior Acknowledged
    Location
    programs/offerbook/src/instructions/non_fungible/classic/escrow_classic_nft_withdraw.rs:18-23
    Round
    Main Review

    Description

    The classic NFT withdraw instruction transfers the NFT from the user escrow token account back to the signer’s NFT token account, but the destination token account is not marked as mutable in the account context.

        #[account(
            token::mint = nft_mint,
            token::authority = signer,
            token::token_program = token_program,
        )]
        pub signer_nft_token_account: Box<InterfaceAccount<'info, TokenAccount>>,
    

    Because the account is missing mut, the generated IDL exposes the signer NFT token account as readonly, even though its balance must be updated to receive the NFT. Standard IDL-driven clients that call the withdraw instruction directly will therefore submit a readonly destination account, while the SPL Token transfer requires that destination account to be writable.

    The transaction fails with a writable privilege escalation error, blocking public IDL users from withdrawing escrowed classic NFTs through the standalone withdraw instruction. Success currently depends on the client adding a separate instruction that makes the destination token account writable at the transaction level.

  13. L-10 Low Loan interest can round down to zero Rounding Acknowledged
    Location
    programs/offerbook/src/utils.rs
    Round
    Main Review

    Description

    compute_interest_amount computes interest with a single truncating integer division and no minimum-interest floor:

    let interest_denominator: u128 = 10_000 * 31_536_000; // 315_360_000_000
    let interest: u128 = principal_amount * apy * duration / interest_denominator;
    

    Interest is therefore 0 whenever principal_amount * apy * duration < 315_360_000_000. Offer creation only enforces apy > 0, principal_amount > min_principal_amount, and a duration within bounds. It never checks that the resulting interest is non-zero, and neither does the fill path. As a result a loan can be created and serviced with a non-zero advertised APY but zero accrued interest.

    Because compute_protocol_fee and compute_repay_amounts derive from interest, a zero-interest loan also produces a zero protocol fee, zero repay fee, and net_principal_amount == principal_amount. The borrower receives the full principal and repays exactly the principal: a free loan.

    Example with the default config (min_principal_amount = 10_000, duration fixed at 3 days = 259_200):

    • principal = 10_001, apy = 100 (1%), duration = 259_200
    • 10_001 * 100 * 259_200 = 259_225_920_000 < 315_360_000_000, so interest = 0

    The same truncation applies per fill on partially fillable offers: each fill slice (at least min_fill_amount, which is above min_principal_amount) computes interest on the slice, so splitting a low-APY offer into small slices can drive the interest on each slice, and thus the total, to zero even when a single full fill would have accrued interest.

    Recommendation

    Reject loans that would accrue zero interest. After computing interest in the fill path (or inside compute_loan_amounts / compute_loan_amounts_with_terms), require interest > 0, returning an explicit error otherwise.

  14. L-11 Low Auth rule pNFTs can brick escrow withdrawals Logical Error Acknowledged
    Location
    escrow_programmable_nft_withdraw()
    Round
    Main Review

    Description

    A programmable NFT can have transfer rules, like allow transfer only if this extra proof / payload is provided or allow transfer only to / from certain owners

    Offerbook supports pNFT escrow, but its withdraw instruction always tells Metaplex: transfer this pNFT with authorization_data: None

    The public withdraw entrypoint has no argument for authorization data, then the withdraw code calls .transfer_nft(None), so

    1. User deposits pNFT into Offerbook
    2. Offerbook moves it into a token account owned by the user signer_user PDA, not the user wallet
    3. Deposit also closes the user’s original NFT token accoun in escrow_programmable_nft_deposit.rs:152
    4. Later, only Offerbook can sign as that PDA to return the NFT
    5. But Offerbook’s withdraw path cannot pass the extra pNFT authorization payload
    6. If the pNFT rule set requires that payload, Metaplex rejects the transfer
    7. The NFT stays stuck in the PDA escrow account

    Recommendation

    Consider exposing authorization data on the public instruction

  15. L-12 Low NFT misclassified as programmable deposit Validation Acknowledged
    Location
    escrow_programmable_nft_deposit()
    Round
    Main Review

    Description

    #[account(
        mut,
        constraint = nft_metadata.key() == Metadata::find_pda(&nft_mint.key()).0
    )]
    pub nft_metadata: Account<'info, MetadataAccount>,
    

    escrow_programmable_nft_deposit never checks

    is_programmable_nft_metadata(&nft_metadata)
    

    So a classic Metaplex NFT can be deposited through the programmable NFT escrow path, and the program emits

    EventAsset::ProgrammableNft(EventProgrammableNftAsset {
        mint: ctx.accounts.nft_mint.key(),
        token_program: ctx.accounts.token_program.key(),
    })
    

    even though the metadata may be NonFungible or None, not ProgrammableNonFungible

    So escrow_programmable_nft_deposit accepts any Metaplex master edition NFT with supply 1 and decimals 0, but it does not require the metadata token standard to be ProgrammableNonFungible

    As a result, classic NFTs can be deposited through the programmable NFT escrow instruction and emit EscrowDeposit events as EventAsset::ProgrammableNft. This creates NFT type confusion for indexers, UIs, and any off chain accounting that treats escrow events as canonical

    Recommendation

    use crate::utils::is_programmable_nft_metadata;
    
    #[account(
        mut,
        constraint = nft_metadata.key() == Metadata::find_pda(&nft_mint.key()).0,
        constraint = is_programmable_nft_metadata(&nft_metadata) @ OfferbookError::InvalidAsset,
    )]
    pub nft_metadata: Account<'info, MetadataAccount>,
    
  16. L-13 Low Fee exemption missing for RWA collateral Logical Error Acknowledged
    Location
    compute_claim_amounts()
    Round
    Main Review

    Description

    The docs say if collateral is NFT / RWA, do not take the 0.1% collateral claim fee

    but the program only has two meaningful claim worlds: NFT claim and token claim. If an RWA is a normal token, the code treats it as Asset::Token, not as RWA,

    So it goes through the normal token default path and pays the fee

    The program has Token, ClassicNft, ProgrammableNft, and CoreNft, but no RWA token type or fee exempt flag

    NFT collateral uses a separate no fee claim path in claim_non_fungible_loan.rs, Fungible RWA collateral cannot reach that path

    If a lender claims defaulted RWA collateral, the lender may receive slightly less collateral than promised

    http://docs.jup.ag/user-docs/earn/offerbook/fees-and-costs

    Recommendation

    Snapshot a fee policy at offer / loan creation, or derive it from an on chain allowlist

    pub enum CollateralFeeClass {
        StandardToken,
        RwaFeeExempt,
        NftFeeExempt,
    }
    

    Then in claim settlement

    let protocol_fee = if loan.collateral_fee_class == CollateralFeeClass::StandardToken {
        compute_protocol_fee(loan.collateral_amount, config.liquidation_fee_bps.into())
    } else {
        0
    };
    
  17. I-01 Informational Unbounded Max APY Configuration Validation Acknowledged
    Location
    programs/offerbook/src/state/config.rs:218-222
    Round
    Main Review

    Description

    The set_max_apy configuration setter attempts to validate that the new maximum APY is non-negative, but the input type is u32, so the condition is always true. This means the function effectively accepts any u32 value, including extremely large APY caps. As a result, the config authority can set an arbitrarily high APY cap. This weakens the intended protocol limit and may allow unrealistic offers with APY values that can make interest calculation fail.

    Recommendation

    Consider replacing the ineffective non-negative check with an explicit upper bound that reflects the maximum APY the protocol intends to support.

  18. I-02 Informational Config Updates Emit No Events Events Acknowledged
    Location
    programs/offerbook/src/instructions/update_config.rs:41-84
    Round
    Main Review

    Description

    The update_config instruction can change admin, pause flags, fee rates, reward rates, minimum amounts, duration limits, expiry limits, and maximum APY, but it does not emit any event after applying the update. This creates an observability gap for indexers and monitoring systems that rely on program events to track and reconstruct protocol configuration changes.

    Recommendation

    Consider adding a dedicated config update event and emit it after each successful update.

  19. I-03 Informational Offers Are Not Balance Backed Unexpected Behavior Acknowledged
    Location
    Global
    Round
    Main Review

    Description

    Offer creation stores the maker’s terms but does not lock, reserve, or verify the required escrow balance. Assets are only moved later when another user fills the offer. A maker can therefore publish offers without having the advertised principal or collateral in escrow. They can also withdraw escrowed assets after publishing, while the offer remains active. The offer will still appear fillable until a taker attempts to fill it and the escrow transfer fails.

    Consequently, active offers can represent fake or stale liquidity, causing takers and bots to waste transactions on fills that revert and degrading the offerbook reliability.

    Recommendation

    Consider verifying and locking the required maker assets at offer creation, then release unused funds on cancellation, expiry, or fill.

  20. I-04 Informational Docs deviate from market logic Documentation Acknowledged
    Round
    Main Review

    Description

    The public Offerbook docs describe market behavior that is not enforced by the on-chain program.

    First, collateral eligibility is presented as market curated: verified Jupiter tokens, RWAs such as xStocks, and NFTs from whitelisted collections. On-chain, this is not enforced.

    Second, the docs describe counter-offer acceptance as an action by the original offer creator that consumes the original offer and makes sibling counter offers no longer actionable. On-chain, filling a counter offer does not consume, cancel, or close the referenced original offer or any other pending counter offers.

    Third, the docs and interface expose LTV, collateral prices, and suggested market terms. However, the program does not store or validate an LTV, price, floor, oracle result, or Jupiter Pricing API value.

    These assumptions can lead to unexpected behavior and risks for integrators.

    Recommendation

    Update the docs and integration guidance to separate interface/indexer behavior from program guarantees.

  21. I-05 Informational Counter offer UI can confuse NFT collateral Warning Acknowledged
    Location
    Counter offer UI
    Round
    Main Review

    Description

    NFT counter offers are linked through countered_offer, but the link does not require the counter offer's collateral asset or filter to match the referenced offer. This is expected on-chain behavior: the selected offer's own collateral terms are what matter at fill time.

    However, beware of UI integration risk. A counter offer may point to Alice's original offer for a common NFT while requiring a different specific NFT or collection. If the UI presents the action as simply accepting a counter to Alice's original offer and does not clearly display the selected counter offer's actual collateral requirement, Alice may sign a fill that escrows a different NFT than she expected.

    This is not an on-chain authorization bypass. The transaction still fills the selected offer and must pass that offer's collateral validation. The risk is user confusion from treating countered_offer as if it preserves or inherits the referenced offer's collateral terms.

    Recommendation

    Display the selected counter offer's actual collateral asset or filter on every accept screen and transaction confirmation. Do not infer collateral display from the referenced countered_offer. If counter offers are intended to modify an existing offer rather than act as independent linked offers, enforce collateral compatibility in the off-chain builder or add on-chain counter-acceptance rules.

  22. I-06 Informational Counters can point to expired offers Unexpected Behavior Acknowledged
    Location
    create_token_collateral_offer.rs:74, create_token_principal_offer.rs:75
    Round
    Main Review

    Description

    Counter-offer creation checks the referenced offer's side, creator, status, and asset compatibility. It does not check countered_offer.expired_at. Since offers do not automatically change status at expiry, an expired offer can remain Active or PartiallyFilled and still pass the counter-offer constraints.

    The created counter offer can be valid and fillable on its own terms. The issue is the relationship stored in countered_offer: it can point at an offer that is no longer fillable even though code comments and route comments describe the referenced offer as fillable.

    The risk is integration drift for UIs and indexers that group counters under the referenced offer. A user may see a new counter attached to an expired offer and interpret it as an actionable modification of that offer, even though the original offer can no longer be filled.

    Recommendation

    If countered_offer is intended to reference only live opportunities, add an expiry check during counter-offer creation. If expired references are acceptable because counters are independent offers, update route and program comments so clients treat countered_offer as a historical relationship rather than proof that the referenced offer is currently fillable.

  23. I-07 Informational NFT claim event implies escrow custody Events Acknowledged
    Location
    claim_non_fungible_loan.rs
    Round
    Main Review

    Description

    NFT default claims transfer collateral to lender custody, then emit EscrowDeposit with user: signer, deposit_amount: 1, and total_amount: 1. For programmable NFTs the Token Metadata transfer uses the lender signer as destination_owner. For Core NFTs the transfer changes direct asset ownership to the lender signer. Classic NFT claims transfer to the supplied token account, which is separately covered by the classic destination report.

    This differs from token-collateral claims, where the program transfers collateral into the lender user's escrow ATA before emitting EscrowDeposit with the actual token-account balance. An event-driven indexer or UI that treats every EscrowDeposit as a protocol escrow balance update can mark a claimed NFT as deposited in Offerbook escrow even though the asset was released to lender custody.

    Recommendation

    Use a distinct event for default claims, or include the actual destination owner and destination account in the NFT claim event. If EscrowDeposit is intentionally reused for custody releases, document that NFT claim events do not necessarily mean the asset is held in the lender's Offerbook escrow.

  24. I-08 Informational Active Loan Fees Are Mutable Unexpected Behavior Acknowledged
    Location
    https://github.com/GuardianOrg/offerbook-program-team1-1781572518472/blob/34e7b2889ed037ebed2cad878347ee0255fc257a/programs/offerbook/src/utils.rs#L166-L198 https://github.com/GuardianOrg/offerbook-program-team1-1781572518472/blob/34e7b2889ed037ebed2cad878347ee0255fc257a/programs/offerbook/src/utils.rs#L208-L239 https://docs.jup.ag/user-docs/earn/offerbook/fees-and-costs
    Round
    Main Review

    Description

    Loans snapshot the main loan terms, such as APY, duration, principal amount, collateral amount, and interest, but they do not snapshot the protocol fee rates that will be applied later during repayment or default claim. The docs state: “Protocol fees are set in the onchain configuration and can be updated by protocol admins”. However, they do not clearly state that updated fees apply to already active loans.

    When a borrower repays or a lender claims token collateral after default, the program recomputes the applicable protocol fee using the current Config values. Since the admin can update repayment, liquidation, referral, and referee fee parameters after a loan is created, the effective economics of an already active loan may differ from what users expected at fill time.

    Recommendation

    Clearly document that active loans remain subject to future protocol fee configuration changes or snapshot the relevant fee bps into the loan account at creation.

  25. I-09 Informational Config PDA Not Enforced Best Practices Acknowledged
    Location
    Global
    Round
    Main Review

    Description

    The protocol treats Config as an authority object, but most instructions only require a valid Offerbook-owned Config account instead of enforcing the canonical PDA. Only initialization pins Config to the expected seeds, while consumers trust whichever valid account is supplied. Anchor verifies ownership and discriminator for this account type, so an arbitrary user does not currently appear able to create a fake valid Config account. However, if another initialized account ever exists under the program (e.g. through a future update or migration), the missing PDA constraint would let callers choose that account instead of the singleton config, causing protocol logic to execute with unintended parameters.

    Recommendation

    Consider enforcing the canonical Config PDA everywhere it is consumed, not only during initialization.

  26. I-10 Informational Dead Collection Attribute Filter Superfluous Code Acknowledged
    Location
    programs/offerbook/src/instructions/non_fungible/fill_non_fungible_principal_offer.rs:756
    Round
    Main Review

    Description

    CollectionWithAttribute is defined as an AssetFilter and is also exposed through generated types and events, but the collateral validation paths do not support it. For metadata NFTs it is explicitly rejected, and for Core NFTs it falls through to the default rejection branch. Since no current validation path accepts it, the filter is effectively unused dead code that exposes a non-functional protocol option.

    Recommendation

    Remove CollectionWithAttribute from the exposed protocol surface or clearly document it as unsupported until validation is implemented.

  27. I-11 Informational Classic NFT Loans Strand Empty ATAs Unexpected Behavior Acknowledged
    Location
    https://github.com/GuardianOrg/offerbook-program-team1-1781572518472/blob/34e7b2889ed037ebed2cad878347ee0255fc257a/programs/offerbook/src/instructions/non_fungible/fill_non_fungible_principal_offer.rs#L396-L410 http://github.com/GuardianOrg/offerbook-program-team1-1781572518472/blob/34e7b2889ed037ebed2cad878347ee0255fc257a/programs/offerbook/src/instructions/non_fungible/fill_non_fungible_principal_offer.rs#L296-L313
    Round
    Main Review

    Description

    There is an escrow lifecycle asymmetry between programmable NFTs and classic NFTs. For pNFTs, after the NFT is transferred from the borrower escrow ATA into the loan vault, the program closes the emptied borrower escrow account. For classic NFTs, the NFT is also moved into the loan vault, but the empty protocol-controlled borrower escrow ATA is left open. Keeping the account open can make repayment simpler because the collateral can be returned to an existing ATA. However, if the loan defaults, the NFT is claimed by the lender and the borrower escrow ATA remains empty and unused.

    Recommendation

    Consider closing the classic NFT borrower escrow ATA after the collateral is locked, mirroring the pNFT flow.

  28. I-12 Informational Config min hikes strand live offers Warning Acknowledged
    Location
    fill_token_principal_offer.rs, fill_token_collateral_offer.rs
    Round
    Main Review

    Description

    The fungible fill paths re-validate the computed amount against the current Config minimums, not the values that were in force when the offer was created. Offers only check > config.min_* at creation time and then store their fixed terms.

    An admin who later raises min_collateral_amount or min_principal_amount above an existing offer's fill amount turns that offer into a permanent revert. The offer remains Active or PartiallyFilled in the book, but every fill attempt now fails with InvalidFillAmount. For a partially-fillable offer that has already taken some fills, this bricks the remaining capacity in place.

    Recommendation

    Snapshot the relevant minimums onto the offer at creation and validate fills against the snapshot, or explicitly document that tightening min_principal_amount / min_collateral_amount retroactively disables live offers.

  29. I-13 Informational Misdocumented Repay Fee Documentation Acknowledged
    Location
    programs/offerbook/src/state/config.rs:63
    Round
    Main Review

    Description

    The default config comment states that repay_fee_bps: 1000 is “10% of repay amount,” but the implementation applies repay_fee_bps only to loan.interest. This makes the documented fee base much larger than the actual charged base and can mislead integrators and users who rely on the default config comments to model loan economics.

    Recommendation

    Update the comment to state that repay_fee_bps is applied to loan interest.

  30. I-14 Informational Undocumented LoanType Field Documentation Acknowledged
    Location
    programs/offerbook/src/state/loan.rs:25-30
    Round
    Main Review

    Description

    LoanType is stored on every loan and emitted in events, but only fungible fill callers can choose it. NFT loans silently default to Classic, while fungible loans can be freely labeled either Classic or Leverage by the filler. Since Leverage has no documented or enforced behavior, the field is currently superfluous metadata that may confuse off-chain consumers.

    Recommendation

    Document LoanType semantics clearly, or consider rejecting Leverage type until leverage-specific behavior is implemented.

Remediation Review

6 findings · July 18 to 24, 2026
  1. M-01 Medium Closing escrow blocks classic NFT repayment DoS Acknowledged
    Location
    programs/offerbook/src/instructions/non_fungible/fill_non_fungible_principal_offer.rs::PrincipalClassicNftAccounts::lock_nft
    Round
    Remediation Review

    Description

    The I-11 "Classic NFT Loans Strand Empty ATAs" remediation closes borrower_collateral_escrow after transferring a classic NFT into the loan vault. However, RepayClassicNftAccounts still requires the same address to deserialize as an initialized token account and does not recreate it:

    #[account(
        mut,
        token::mint = nft_mint,
        token::token_program = collateral_token_program,
    )]
    pub borrower_collateral_escrow: Box<InterfaceAccount<'info, TokenAccount>>,
    

    Anchor therefore rejects standard classic NFT repayment with AccountNotInitialized before the repayment handler executes. This affects both NFT offer directions and was reproduced by the Surfpool suite. Unless the borrower manually recreates the protocol controlled ATA, the loan remains active and the lender can claim the NFT after expiry even when the borrower is willing and able to repay.

    Recommendation

    Keep the classic NFT borrower escrow ATA open after fill, or recreate it atomically during repayment with the signer user PDA as its authority and the correct NFT mint and token program before returning the collateral.

  2. L-01 Low Extensions bypass the emergency pause Warning Acknowledged
    Location
    programs/offerbook/src/instructions/fungible/extend_loan.rs::extend_loan
    Round
    Remediation Review

    Description

    extend_loan uses the repayment pause check, which permits execution while the protocol is paused whenever disable_repayment is false:

    let config = ctx.accounts.config.load()?;
    config.fail_if_paused_and_disable_repayment()?;
    

    An extension does more than repay an existing obligation. It charges a new origination fee and resets expiry to current_timestamp + duration. A borrower can therefore originate another loan period and keep principal and collateral committed during an incident, even though new offers, fills, and claims are blocked by the strict pause check.

    Recommendation

    Use fail_if_paused() in extend_loan. If extensions should remain available under selected pause conditions, introduce a separate explicit extension pause policy instead of inheriting the repayment exception.

  3. L-02 Low Extension emits zero-amount referral rewards Rounding Acknowledged
    Location
    programs/offerbook/src/instructions/fungible/extend_loan.rs
    Round
    Remediation Review

    Description

    extend_loan emits referral reward events whenever the aggregate referral reward is non-zero, but it does not re-check the split reward amount before emitting the lender and borrower events. When both lender and borrower are referred, the code divides the aggregate reward by two:

    let reward_amount = if both_referred {
        open.referral_reward / 2
    } else {
        open.referral_reward
    };
    

    If the aggregate reward is 1, reward_amount rounds down to 0, yet both ReferralReward events are still emitted. The fuzzer reproduced this with an extension where the close-period referral rewards were non-zero, but the new-period origination fee was tiny enough that the split open-period reward rounded to zero. The transaction emitted two legitimate amount: 135 referral events and two extra amount: 0 referral events.

    Event-driven indexers, analytics, or referral accounting jobs may record phantom reward events, overcount reward occurrences, and display misleading referral activity for extensions with small fees.

    Recommendation

    In extend_loan, gate each split referral reward emission on the actual reward_amount > 0, matching the harness oracle and the intent already used for aggregate reward checks. Apply the same pattern to both close-period and open-period referral reward emission blocks.

  4. I-01 Informational Offer events omit extendability Events Acknowledged
    Location
    programs/offerbook/src/events.rs::OfferEventV1
    Round
    Remediation Review

    Description

    Offer accounts store allow_extend, and loans filled from those offers inherit the value. However, OfferEventV1 and its From<Offer> conversion omit the field. Event based indexers and interfaces therefore cannot determine whether an active offer permits loan extensions. This hides a material term until the offer account is fetched directly or the resulting loan is created, which is particularly relevant when a lender considers a collateral offer created by a borrower.

    Recommendation

    Introduce a new versioned offer event containing allow_extend and emit it from offer creation, cancellation, and fill paths. Keep OfferEventV1 unchanged so historical event decoding remains compatible.

  5. I-02 Informational Token-2022 mutability remains Warning Acknowledged
    Location
    programs/offerbook/src/utils.rs::validate_mint_extensions
    Round
    Remediation Review

    Description

    M-02 "Token-2022 mint mutability bypass" is partially fixed. The remediation revalidates Token-2022 mint extensions when an offer is filled, closing the window in which unsafe settings could be enabled between offer creation and fill. However, validate_mint_extensions still permits a configured transfer fee authority and transfer hook authority, and the mint is not revalidated during repayment or claim. An authority can therefore enable a fee or hook after fill, causing later loan transfers to deliver less than expected or revert.

    The team intentionally accepts this residual risk to support xStocks and a wider range of Token-2022 assets.

    Recommendation

    Clearly document that supported Token-2022 assets may retain authorities capable of changing transfer behavior during an active loan. User facing documentation and interfaces should warn users to inspect the mint's extensions and authorities, assess the issuer or administrator controlling them, and perform their own due diligence before creating or accepting offers involving these tokens.

  6. I-03 Informational Filtered counter collateral can mismatch Warning Acknowledged
    Location
    programs/offerbook/src/instructions/non_fungible/create_non_fungible_collateral_offer.rs
    Round
    Remediation Review

    Description

    I-05 "Counter offer UI can confuse NFT collateral" is partially fixed. The remediation requires matching collateral when the referenced offer identifies a concrete NFT. However, principal offers using a collection or first verified creator filter store Asset::None as their collateral and keep the actual restriction in offer.filter. The collateral counter path accepts any concrete NFT whenever the referenced collateral is Asset::None:

    let countered_collateral = countered.load()?.collateral;
    require!(
        countered_collateral == Asset::None || offer.collateral == countered_collateral,
        OfferbookError::InvalidAsset
    );
    

    The referenced filter is never checked, so a counter can point to an offer for collection A while naming an NFT from collection B. The selected counter's own collateral is still enforced at fill, so this is not an authorization bypass. The remaining risk is user and integration confusion if countered_offer is presented as proof that the collateral terms are compatible.

    Recommendation

    When the referenced offer uses Asset::None, validate the counter's NFT against the referenced collection or creator filter using the required metadata or asset accounts. If this cannot be enforced during creation, prevent filtered offers from being linked as compatible counters, or require clients to perform the check and clearly display the selected counter's actual collateral.

More from Jupiter

  1. Yield Store

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

    11 findings 11 findings: 11 informational
  3. JupUSD Stablecoin

    18 findings 18 findings: 9 low, 9 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