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
Scope
43 files in scope · 6,691 nSLOC
| File | nSLOC | Lines |
|---|---|---|
.../fill_non_fungible_principal_offer.rs | 742 | 857 |
.../fill_non_fungible_collateral_offer.rs | 606 | 699 |
.../repay_non_fungible_loan.rs | 545 | 628 |
.../claim_non_fungible_loan.rs | 396 | 454 |
.../fill_token_principal_offer.rs | 366 | 435 |
.../fill_token_collateral_offer.rs | 361 | 432 |
programs/offerbook/src/events.rs | 351 | 392 |
.../repay_token_loan.rs | 268 | 313 |
.../claim_token_loan.rs | 224 | 261 |
programs/offerbook/src/state/offer.rs | 204 | 253 |
programs/offerbook/src/utils.rs | 189 | 240 |
.../create_token_principal_offer.rs | 177 | 238 |
.../create_token_collateral_offer.rs | 176 | 239 |
.../create_non_fungible_principal_offer.rs | 176 | 232 |
programs/offerbook/src/state/config.rs | 175 | 223 |
programs/offerbook/src/lib.rs | 167 | 203 |
.../create_non_fungible_collateral_offer.rs | 153 | 208 |
.../escrow_programmable_nft_withdraw.rs | 148 | 174 |
.../escrow_programmable_nft_deposit.rs | 145 | 168 |
programs/offerbook/src/state/loan.rs | 108 | 140 |
.../escrow_classic_nft_deposit.rs | 101 | 118 |
programs/offerbook/src/state/asset.rs | 93 | 113 |
.../escrow_classic_nft_withdraw.rs | 86 | 99 |
programs/offerbook/src/instructions/update_config.rs | 71 | 85 |
.../escrow_core_nft_withdraw.rs | 70 | 86 |
.../escrow_token_deposit.rs | 68 | 79 |
.../escrow_core_nft_deposit.rs | 68 | 84 |
programs/offerbook/src/state/user.rs | 68 | 87 |
.../escrow_token_withdraw.rs | 67 | 79 |
programs/offerbook/src/instructions/claim_fee.rs | 58 | 72 |
programs/offerbook/src/instructions/create_user.rs | 55 | 66 |
programs/offerbook/src/error.rs | 46 | 47 |
programs/offerbook/src/instructions/init.rs | 38 | 48 |
programs/offerbook/Cargo.toml | 27 | 34 |
programs/offerbook/src/instructions/cancel_offer.rs | 25 | 32 |
.../non_fungible/mod.rs | 18 | 20 |
.../fungible/mod.rs | 16 | 17 |
programs/offerbook/src/instructions/mod.rs | 14 | 15 |
programs/offerbook/src/state/mod.rs | 10 | 11 |
.../classic/mod.rs | 4 | 5 |
.../mpl_core/mod.rs | 4 | 5 |
.../programmable/mod.rs | 4 | 5 |
programs/offerbook/src/constants.rs | 3 | 4 |
Findings 36
Main Review
30 findings · June 22 to July 8, 2026-
M-01 Medium Token Delegates Can Block Collateral Transfer Warning Acknowledged
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 thePermanentDelegateextension, and it also allows collateral mints with other external control surfaces such as a configured freeze authority or the Token-2022Pausableextension.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
PermanentDelegateconfigured. Also reject mints with retained freeze authority orPausablecontrol unless the protocol explicitly intends to trust that authority. -
M-02 Medium Token-2022 mint mutability bypass Logical Error Acknowledged
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_authorityto be unset whenTransferFeeConfigis present, and requireauthorityto be unset whenTransferHookis present. -
M-03 Medium Unsafe Token-2022 NFT Collateral Logical Error Acknowledged
Description
NFT collateral can be supplied across three standards (classic, programmable, and MPL Core). Broad
CollectionorFirstVerifiedCreatoroffers 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 == 1anddecimals == 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_rulesaccount 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 theVerifiedCreatorsplugin, 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.
- Classic: validate classic NFT collateral mints before accepting them into a loan. Reject Token-2022 NFT collateral mints with
-
L-01 Low Referral Rewards Are Only Events Gaming Acknowledged
Description
In every fee-bearing path the full computed
protocol_feeis 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 emitReferralRewardevents 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.
-
L-02 Low Single-Step Admin Transfer Access Control Acknowledged
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.
-
L-03 Low Fee Math Can Block Claims Math Acknowledged
Description
The
compute_protocol_feehelper performs multiplication in u64 before dividing by10_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_bpsexceedsu64::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.
-
L-04 Low Counter offers can be sniped Frontrunning Acknowledged
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_offeris 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.
-
L-05 Low Partial fill rounding undercollateralizes loans Rounding Acknowledged
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_loanmarks the offerFulfilledas 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 leftoverremaining_collateralcapacity unenforced.Impact: Under default configuration this is dust. Each fill must lock more than
min_collateral_amount(and more thanmin_principal_amounton the other path), which bounds the total relative undercollateralization to1 / min_collateral_amount.With the default
min_collateral_amount = 10_000the worst case is< 0.01%, which is economically negligible. The issue only becomes material if an admin lowersmin_collateral_amount(ormin_principal_amount) toward single digits, which simultaneously defeats the dust guard those parameters exist to provide.set_min_collateral_amountonly enforces> 0, so a misconfiguration to1or2is 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_amountandmin_collateral_amountset high in the collateral token's real decimals in production - Consider enforcing a sane lower bound in
set_min_collateral_amount/set_min_principal_amountbeyond> 0.
- Keep
-
L-06 Low Core collection filter skips membership check Warning Acknowledged
Description
NFT principal offers can use a broad
Collectionfilter, and the concrete collateral is resolved at fill time. For MPL Core collateral, the filter check only compares the pubkey of the separately suppliedcollectionaccount to the filter value. It never reads the locked asset's own collection membership from theBaseAssetV1account.get_collectionreturns the key of the passedcollectionaccount, andvalidate_filtercompares that key tocollection_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_metadatato the locked mint viaMetadata::find_pdaand require averifiedcollection, and the CoreFirstVerifiedCreatorbranch reads theVerifiedCreatorsplugin directly off the asset.What currently prevents a mismatch is external, not this check:
lock_nftinvokes the MPL CoreTransferV1with bothassetandcollection, 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
Collectionbranch ofvalidate_filter, read the update authority from theBaseAssetV1asset account and require it equalsUpdateAuthority::Collection(collection_filter.collection), mirroring how theFirstVerifiedCreatorbranch reads the plugin off the asset. -
L-07 Low FVC filter trusts creator key Warning Acknowledged
Description
Principal side NFT offers can be scoped with a
FirstVerifiedCreator(FVC) filter. For these broad offers, creation storesAsset::Noneplus 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 theVerifiedCreatorsplugin 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.
-
L-08 Low Classic NFT claim destination is UI-trusted Validation Acknowledged
Description
The classic NFT default-claim path accepts
lender_collateral_escrowas 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
EscrowDepositevent recordsuser: 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 setnew_ownerdirectly 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 looselender_nft_token_account(constrained only by mint and token program), but the Token MetadataTransferV1CPI fixesdestination_ownerto 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 SPLtransfer_checkedto 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 = signeron 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. -
L-09 Low Classic NFT Withdraw Uses Readonly ATA Unexpected Behavior Acknowledged
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.
-
L-10 Low Loan interest can round down to zero Rounding Acknowledged
Description
compute_interest_amountcomputes 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
0wheneverprincipal_amount * apy * duration < 315_360_000_000. Offer creation only enforcesapy > 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_feeandcompute_repay_amountsderive frominterest, a zero-interest loan also produces a zero protocol fee, zero repay fee, andnet_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, sointerest = 0
The same truncation applies per fill on partially fillable offers: each fill slice (at least
min_fill_amount, which is abovemin_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
interestin the fill path (or insidecompute_loan_amounts/compute_loan_amounts_with_terms), requireinterest > 0, returning an explicit error otherwise. - principal =
-
L-11 Low Auth rule pNFTs can brick escrow withdrawals Logical Error Acknowledged
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
- User deposits pNFT into Offerbook
- Offerbook moves it into a token account owned by the user signer_user PDA, not the user wallet
- Deposit also closes the user’s original NFT token accoun in escrow_programmable_nft_deposit.rs:152
- Later, only Offerbook can sign as that PDA to return the NFT
- But Offerbook’s withdraw path cannot pass the extra pNFT authorization payload
- If the pNFT rule set requires that payload, Metaplex rejects the transfer
- The NFT stays stuck in the PDA escrow account
Recommendation
Consider exposing authorization data on the public instruction
-
L-12 Low NFT misclassified as programmable deposit Validation Acknowledged
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
NonFungibleorNone, notProgrammableNonFungibleSo 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
EscrowDepositevents asEventAsset::ProgrammableNft. This creates NFT type confusion for indexers, UIs, and any off chain accounting that treats escrow events as canonicalRecommendation
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>, -
L-13 Low Fee exemption missing for RWA collateral Logical Error Acknowledged
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
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 }; -
I-01 Informational Unbounded Max APY Configuration Validation Acknowledged
Description
The
set_max_apyconfiguration 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.
-
I-02 Informational Config Updates Emit No Events Events Acknowledged
Description
The
update_configinstruction 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.
-
I-03 Informational Offers Are Not Balance Backed Unexpected Behavior Acknowledged
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.
-
I-04 Informational Docs deviate from market logic Documentation Acknowledged
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.
-
I-05 Informational Counter offer UI can confuse NFT collateral Warning Acknowledged
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_offeras 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. -
I-06 Informational Counters can point to expired offers Unexpected Behavior Acknowledged
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 remainActiveorPartiallyFilledand 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_offeris 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 treatcountered_offeras a historical relationship rather than proof that the referenced offer is currently fillable. -
I-07 Informational NFT claim event implies escrow custody Events Acknowledged
Description
NFT default claims transfer collateral to lender custody, then emit
EscrowDepositwithuser: signer,deposit_amount: 1, andtotal_amount: 1. For programmable NFTs the Token Metadata transfer uses the lender signer asdestination_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
EscrowDepositwith the actual token-account balance. An event-driven indexer or UI that treats everyEscrowDepositas 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
EscrowDepositis intentionally reused for custody releases, document that NFT claim events do not necessarily mean the asset is held in the lender's Offerbook escrow. -
I-08 Informational Active Loan Fees Are Mutable Unexpected Behavior Acknowledged
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
Configvalues. 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.
-
I-09 Informational Config PDA Not Enforced Best Practices Acknowledged
Description
The protocol treats
Configas an authority object, but most instructions only require a valid Offerbook-ownedConfigaccount instead of enforcing the canonical PDA. Only initialization pinsConfigto 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 validConfigaccount. 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
ConfigPDA everywhere it is consumed, not only during initialization. -
I-10 Informational Dead Collection Attribute Filter Superfluous Code Acknowledged
Description
CollectionWithAttributeis defined as anAssetFilterand 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
CollectionWithAttributefrom the exposed protocol surface or clearly document it as unsupported until validation is implemented. -
I-11 Informational Classic NFT Loans Strand Empty ATAs Unexpected Behavior Acknowledged
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.
-
I-12 Informational Config min hikes strand live offers Warning Acknowledged
Description
The fungible fill paths re-validate the computed amount against the current
Configminimums, 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_amountormin_principal_amountabove an existing offer's fill amount turns that offer into a permanent revert. The offer remainsActiveorPartiallyFilledin the book, but every fill attempt now fails withInvalidFillAmount. 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_amountretroactively disables live offers. -
I-13 Informational Misdocumented Repay Fee Documentation Acknowledged
Description
The default config comment states that
repay_fee_bps: 1000is “10% of repay amount,” but the implementation appliesrepay_fee_bpsonly toloan.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_bpsis applied to loan interest. -
I-14 Informational Undocumented LoanType Field Documentation Acknowledged
Description
LoanTypeis stored on every loan and emitted in events, but only fungible fill callers can choose it. NFT loans silently default toClassic, while fungible loans can be freely labeled eitherClassicorLeverageby the filler. SinceLeveragehas no documented or enforced behavior, the field is currently superfluous metadata that may confuse off-chain consumers.Recommendation
Document
LoanTypesemantics clearly, or consider rejectingLeveragetype until leverage-specific behavior is implemented.
Remediation Review
6 findings · July 18 to 24, 2026-
M-01 Medium Closing escrow blocks classic NFT repayment DoS Acknowledged
Description
The I-11 "Classic NFT Loans Strand Empty ATAs" remediation closes
borrower_collateral_escrowafter transferring a classic NFT into the loan vault. However,RepayClassicNftAccountsstill 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
AccountNotInitializedbefore 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.
-
L-01 Low Extensions bypass the emergency pause Warning Acknowledged
Description
extend_loanuses the repayment pause check, which permits execution while the protocol is paused wheneverdisable_repaymentis 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()inextend_loan. If extensions should remain available under selected pause conditions, introduce a separate explicit extension pause policy instead of inheriting the repayment exception. -
L-02 Low Extension emits zero-amount referral rewards Rounding Acknowledged
Description
extend_loanemits 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_amountrounds down to0, yet bothReferralRewardevents 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 legitimateamount: 135referral events and two extraamount: 0referral 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 actualreward_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. -
I-01 Informational Offer events omit extendability Events Acknowledged
Description
Offer accounts store
allow_extend, and loans filled from those offers inherit the value. However,OfferEventV1and itsFrom<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_extendand emit it from offer creation, cancellation, and fill paths. KeepOfferEventV1unchanged so historical event decoding remains compatible. -
I-02 Informational Token-2022 mutability remains Warning Acknowledged
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_extensionsstill 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.
-
I-03 Informational Filtered counter collateral can mismatch Warning Acknowledged
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::Noneas their collateral and keep the actual restriction inoffer.filter. The collateral counter path accepts any concrete NFT whenever the referenced collateral isAsset::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_offeris 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.
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.
