Sanctum: Sols
Cantina Security Report
Organization
- @sanctum
Engagement Type
Cantina Reviews
Period
-
Researchers
Findings
High Risk
3 findings
3 fixed
0 acknowledged
Medium Risk
5 findings
4 fixed
1 acknowledged
Low Risk
2 findings
1 fixed
1 acknowledged
Informational
10 findings
4 fixed
6 acknowledged
High Risk3 findings
Lack of PDA check on TWTM leads to user wallet account draining
State
- Fixed
PR #46
Severity
- Severity: High
Submitted by
Mustafa Hasan
Finding Description
The
wsol_bridgeaccount needs to be a signer in the transaction in order for allocation and assignment to pass, however there are no checks that the account is a PDA, allowing it to be a user wallet. In that case, an attacker that could lure a user to sign an innocent-looking transaction for the TWTM instruction can supplywsol_bridgeas the signer's pubkey. Since the user is signing the transaction anyways, the account will be allocated, assigned, then closed, draining all the lamports in the account and allowing the attacker to later permissionlessly claim them from the pool.Recommendation
Check that the
wsol_bridgeis a PDA and revert otherwise.Stale sol_value Base in Rebalance Output Entry Update
Description
When updating the output holding's sol_value after a rebalance, both the SOL→Token and Token→Token paths subtract the fresh CPI-derived quote amount from the stale stored sol_value rather than from the fresh out_sol_val fetched via cpi_lst_to_sol_id earlier in the same instruction:
out_entry.lamports.set_sol_value( out_entry .lamports .sol_value() // stale stored value .checked_sub(*quote.qtys.sol_value().out()) // derived from fresh out_sol_val .ok_or(SanctumSolsProgErr(SanctumSolsErr::Math))?,);This produces two failure modes. When an LST has appreciated, the fresh-derived subtraction exceeds the stale base, causing checked_sub to underflow and reject an otherwise valid rebalance. When an LST has depreciated the subtraction succeeds but leaves the holding's stored sol_value incorrect, silently corrupting accounting for all subsequent quotes, claims, and rebalances.
Recommendation
Replace the stale out_entry.lamports.sol_value() base with the fresh out_sol_val in both affected paths. Possible fix:
out_entry.lamports.set_sol_value( out_sol_val .checked_sub(*quote.qtys.sol_value().out()) .ok_or(SanctumSolsProgErr(SanctumSolsErr::Math))?,);Same pool holding swaps allow unbacked SOLS minting
State
- Fixed
PR #48
Severity
- Severity: High
Submitted by
Mustafa Hasan
Finding Description
The holding-based swap path does not prevent
inp_pool == out_pool(and thereforeinp_ata == out_ata). In this self-swap case, the instruction routes throughClaimHoldingwithoutset to the pool’s own holding ATA, causing the holding “transfer” to be a self-transfer (no balance change). Despite no intermediate holding leaving or entering the pool, the swap still executes the deposit leg, increases outstanding, and mintsqtys.outSOLS to the user.When the intermediate holding has appreciated (its fresh SOL value from the SVC CPI exceeds its recorded outstanding), the quote can produce
qtys.out > amt_burnt. This lets an unprivileged attacker increase their SOLS balance without removing any assets from the pool, then cash out by swapping to another pool with SOL liquidity or by claiming SOL.Vulnerability flow
process_swap_other()computesis_self_swapsolely from the holding ATA equality and still proceeds to callprocess_deposit_holding():
let is_self_swap = inp_out.out().holding_ata() == inp_out.inp().holding_ata();process_deposit_holding(abr, cpi, out, ctl_pre, *inp_out.out(), chq, is_self_swap)process_claim_holdingexplicitly treatsout == holding_ataas “relinquish holding” and skips decrementing the holding’s sol_value (because the transfer is a self-transfer):
let is_relinquish_holding = pre.out() == holding.ata();...if !is_relinquish_holding { hold_lamports.set_sol_value(hold.sol_value().checked_sub(*qtys.sol_value().out())?);}- In the deposit leg,
process_deposit_holding()also skips incrementingsol_valueon self-swap, but still increments outstanding and mints SOLS to the user:
if !is_self_swap { lamports.set_sol_value(lamports.sol_value().checked_add(*holding.sol_value())?);}lamports.set_outstanding(lamports.outstanding().checked_add(*qtys.out())?);...process_mint(abr, cpi, &out, *qtys.out(), out_pool_bump)Attack scenario
- An attacker spots a pool with low SOL reserves and an appreciated holding token
- The attacker sends a swap instruction with the input pool equal to the output pool and the appreciated holding token
- The program burns the
raw_amtof SOLS supplied by the attacker - The computed quote is larger than the amount burned since the holding has appreciated
qtys.outtokens are minted to the attacker although no holding tokens were removed from the pool ATA
Recommendation
Revert if the input and output pools are equal.
Medium Risk5 findings
Permissionless MINT Enables Indefinite Pool Closure Griefing
State
- Acknowledged
Severity
- Severity: Medium
≈
Likelihood: Medium×
Impact: Medium Submitted by
Jay
Description
CLOSErequires the SOLS mint supply to be zero before reclaiming the pool account. However, theMINTinstruction requires no signer beyond a valid deposit.. Any user can mint SOLS to themselves permissionlessly. This creates a griefing vector: after an admin has drained the pool to zero supply, an attacker can atomically send 1 lamport surplus to the pool and callMINTto place 1 SOLS into their own token account, failing theMintNotEmpty. The admin has no recourse,MINThas no freeze authority, and SPL token burn requires the holder's signature. The attack is repeatable at approximately 5,000 lamports per attempt and can be executed indefinitely, permanently blocking pool closure at negligible cost.The root cause is that the protocol has no mechanism to transition a pool into a state that blocks new mints while still allowing existing holders to exit. Without a "closing" flag or equivalent, the liveness of pool closure is entirely dependent
Recommendation
Perhaps consider introducing a boolean closing flag on the pool that the admin can set. When closing is true,
MINT(and optionally TTM, TWTM, BTC, BTCW) would be blocked, whileCLAIMandCLAIM_WRAPPED remain open so existing holders can exit. Once supply reaches zero the admin can proceed withCLOSEunimpeded. This would require two new instructions (SET_CLOSING / UNSET_CLOSING) and a flag check in the hot mint path, but represents the minimal change that eliminates the griefing surface.Token-22 Extension Incompatibility in Holding Mint Validation
Description
set_holdingpermits Token-22 mints as holdings but performs no inspection of their extension data. The downstream instructions that operate on holding tokens..claim_holding,rem_holding, and the rebalancing path , all issue CPIs with fixed account sets and no remaining accounts. This makes the system structurally incompatible with any Token-22 mint carrying extensions that require additional accounts or alter transfer semantics.Concretely, a holding mint with transfer hooks would cause
claim_holdingandrem_holdingCPIs to fail outright since the required hook program and extra accounts are never passed. A mint with transfer fees would silently reduce the amount transferred on eachClaimHoldingcall, breaking accounting. A mint with confidential transfers would make the ATA balance opaque to the SOL value calculator. A non-transferable mint would render the holding permanently immovable. In each of these cases, once tokens enter the holding ATA via the rebalancing input path, they can become unclaimable, unrebalancable, and the holding itself unremovable effectively permanently stranding funds in the pool.The manager is a trusted role, but this is an irreversible footgun with no recovery path once an incompatible Token-22 holding has received a balance.
Recommendation
The simplest and most robust fix is to reject all Token-22 mints in set_holding, limiting holdings to Token Keg only. This removes the entire extension incompatibility surface without requiring per-extension validation logic. The owner check already distinguishes the two programs, so banning Token-22 is a one-line addition.
StartRebal SOL-in path enables free extraction of holding tokens when holding.outstanding == 0
State
- Fixed
PR #49
Severity
- Severity: Medium
Submitted by
Mustafa Hasan
Finding Description
The
Ratiofrom Sanctum treats a zero denominator ratio as a zero amount:#[inline]pub const fn apply(&self, amount: u64) -> Option<u64> { if self.0.is_zero() { return Some(0); } let Ratio { n, d } = self.0; let d = d as u128; let n = n as u128; let x = amount as u128; // unchecked-arith: mul will not overflow because // both x and n are <= u64::MAX let xn = x * n; // unchecked-arith: ratio is not 0 so d != 0 let res = xn.div_ceil(d); u128_to_u64_checked(res)}If the passed holding has an
outstanding() == 0(if the holding is new for example, and as long as the holding balance > 0) and when permissionless liquidation is enabled (insufficient SOL reserves), a user can redeem holding tokens without SOL repayment.Attack scenario
The setup is a pool with
curr_rr < rrr_floorthat has a holding token whoseoutstanding == 0and actual balance is > 0. Whenprocess_start_rebal()is called with SOL asinp()andTokenT::Otherasout(),quote_rebal_sol_inp_exact_out()gets called, eventually callingcalc_protocol_fees()withlamports_returningas zero since it is derived fromholding.outstanding(), which is currently zero. As a result, the returned quote will have itsinp() = 0, leading to an anonymous user (since we are in permissionless liquidation mode) being able to extract holding tokens without SOL repayment.Remediation
Guard against cases when
outstanding == 0.SVC programs not checked against whitelist when same mint flashloans occur
State
- Fixed
PR #53
Severity
- Severity: Medium
Submitted by
Mustafa Hasan
Finding Description
The holding SVC program is not checked against the whitelist when a same mint flashloan occurs. This allows the now blacklisted SVC program to be used in the end rebal instruction, enabling any user to overwrite the portfolio holding balance's SOL value with outdated/manipulated values.
Recommendation
Check the SVC program in the holding account against the whitelist in all cases.
Lamport Griefing Attack Blocks ClaimHolding Emergency Exit
Description
The
quote_claim_holdingfunction requiresliq_availto be exactly 0 forClaimHoldingto execute.ClaimHoldingis the emergency exit mechanism that allows users to claim their proportional share of holding LST tokens directly when the pool has no liquid SOL.Since
liq_availequalspool_acc_lamportsminusrent_exempt,anyone can send 1 lamport to the pool account to makeliq_availgreater than 0, which blocks allClaimHoldingcalls. The on chain SOL transfer requires no program interaction and cannot be blocked by the program.Attack Scenario:
- Pool has
liq_availequal to 0 because all SOL has been delegated to holdings via rebalancing. - User burns X SOLS tokens, creating surplus of X for their
ClaimHoldingcall. - Griefer sends 1 lamport to the pool account, making
liq_availequal to 1. - User's
ClaimHoldingreverts becauseliq_availis greater than 0. - Griefer repeats at a cost of approximately 1 lamport per block, indefinitely trapping users.
Recommendation
Modify the
Claiminstruction inclaim.rsso that when no amount is specified, the claimed amount is capped at the minimum of surplus and available liquidity, rather than reverting when surplus exceeds available liquidity. This makes Claim with no amount always succeed, allowing users to atomically construct a transaction where Claim drains all griefed lamports first, restoringliq_availto 0, and thenClaimHoldingexecutes successfully. The fix is race condition free because Claim with no amount drains all available liquidity regardless of the exact amount the griefer sent, and Solana transactions are atomic so no griefing can occur between the two instructions.- Pool has
Low Risk2 findings
Missing Freeze Authority Check on Holding Mints
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Jay
Description
set_holdingvalidates a holding mint viamint_of()but does not check for a freeze authority. This is inconsistent withinit.rs, which explicitly rejects the SOLS mint iffreeze_auth().is_some(). The reasoning there is sound, a mint with a live freeze authority means a third party can freeze any token account for that mint, including the pool's ATA.For holdings this matters because LST issuers (e.g. a regulated stablecoin issuer) retain freeze authority over their mints. If such a token is added as a holding, the issuer could freeze the pool's ATA, blocking
ClaimHoldingwhen SOL reserves are empty and preventing rebalancing out of that holding. The manager is assumed to be trusted, but the protection applied to the SOLS mint is not extended to the assets it backs, creating an inconsistency in the threat model.Recommendation
Apply the same freeze authority check in set_holding that is already enforced in
init.rs:let mint = mint_of(holding_accs.mint())?;if mint.freeze_auth().is_some() { return Err(SanctumSolsProgErr(SanctumSolsErr::InitMintHasFreezeAuth).into());}Holding swaps mint Out-pool SOLS using In-pool SVC program
State
- Fixed
PR #45
Severity
- Severity: Low
Submitted by
Mustafa Hasan
Finding Description
The "swap via holding" path (used when the input pool has no SOL liquidity) works by claiming a holding token from the input pool and transferring it to the output pool’s ATA, then minting output SOLS based on the claimed holding’s SOL value.
In implementation, the output pool’s mint amount is derived from the
ClaimHoldingQuoteproduced for the input pool.process_deposit_holding()explicitly acknowledges that the output pool’s holding entry may have a different SVC program, but still uses the input pool’s valuation as the source of truth. This creates a cross-pool trust boundary violation: the output pool’s solvency and minted supply can be driven by a different calculator program (and calculator account suffix) than the one configured for the output pool.A remote attacker can exploit any valuation gap between the input pool’s configured SVC and the output pool’s intended SVC for the same holding mint to mint excess output SOLS and drain value.
Recommendation
The the output pool's SVC program for the calculation.
Informational10 findings
Incorrect IS_WRITER Constant in TWTM_IX_IS_SIGNER
Description
The
TWTM_IX_IS_SIGNERconstant usesTOKEN_SYS_PROG_IS_WRITERfor its progs field, where it should useTOKEN_SYS_PROG_IS_SIGNER:pub const TWTM_IX_IS_SIGNER: TWTMIxAccFlags = TWTMIxAccFlags { pre: TWTM_IX_PRE_IS_SIGNER, suf: TWTM_IX_SUF_IS_SIGNER, progs: TOKEN_SYS_PROG_IS_WRITER, // <-- wrong constant};Both
TOKEN_SYS_PROG_IS_WRITERandTOKEN_SYS_PROG_IS_SIGNERare defined asTokenSysProgAccFlags::memset(false), so the bug is currently benign runtime behavior is identical. However, the naming contract is broken:IS_SIGNERstructs should use theIS_SIGNERvariant for each nested field to clearly communicate intent. The parallel instruction core/src/instructions/user/transfer_then_mint.rs correctly usesTOKEN_SYS_PROG_IS_SIGNERin its ownTTM_IX_IS_SIGNERconstant, confirming the established pattern.Recommendation
Replace
TOKEN_SYS_PROG_IS_WRITERwithTOKEN_SYS_PROG_IS_SIGNER in TWTM_IX_IS_SIGNER, and addTOKEN_SYS_PROG_IS_SIGNERto the import:use crate::instructions::common::{TokenSysProgs, TOKEN_SYS_PROG_IS_SIGNER, TOKEN_SYS_PROG_IS_WRITER}; pub const TWTM_IX_IS_SIGNER: TWTMIxAccFlags = TWTMIxAccFlags { pre: TWTM_IX_PRE_IS_SIGNER, suf: TWTM_IX_SUF_IS_SIGNER, progs: TOKEN_SYS_PROG_IS_SIGNER, // fixed};Endianness Mismatch in RebalAuxData::from_pked
Description
from_pkedandinto_pkedform an asymmetric round-trip forinp_idx. into_pkedserialises withto_le_bytesbutfrom_pkeddeserialises withfrom_be_bytes, corrupting any non-zero,non-u32::MAXindex on round-trip. The bug is currently latent, no call site forRebalAuxData::from_pkedexists and the onchain path bypasses it entirely via direct pointer casts, but any off-chain client or indexer that callsfrom_pkedon a parsedRebalAuxPkedwould receive a garbageinp_idx.Recommendation
Replace from_be_bytes with
from_le_bytesto restore symmetry withinto_pked:inp_idx: u32::from_le_bytes(inp_idx),Permissionless Mint Design Lacks Adequate Integrator-Facing Warnings
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Description
The MINT instruction is permissionless by design, computing mintable SOLS from dep_due - mint_supply. This means any SOL deposited to the pool outside of an atomic TransferThenMint (TTM/TWTM) sequence can be minted against by any third party. While this design decision is documented in the protocol's internal design notes, there are no prominent warnings at the SDK or ABI level to guide integrators away from constructing unsafe two-step deposit flows.
An integrator building on top of SOLS who separates the SOL transfer and mint call across instructions or transactions would silently expose their users to total loss of deposited funds with no on-chain safeguard or error.
Recommendation
Improve documentation hygiene across the following surfaces:
- SDK/ABI: Add inline warnings on any deposit-related interfaces noting that TTM/TWTM must be used atomically
- README / Integration Guide: Explicitly document that the permissionless mint is an internal protocol primitive and that non-atomic deposit patterns are unsafe
- DESIGN.md: Expand the existing note to include the integrator risk, not just the rationale for the design choice
[Appendix] Formal Verification: mode.rs
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Spec Overview for
core::accounts::mod.rsFunctionality Details Verification Status function is_pool_solvent_iff_dep_due_checked_somePostconditions is_pool_solvent(p) ⟹ dep_due_checked(p).is_some() ✅ Postconditions dep_due_checked(p).is_some() ⟹ is_pool_solvent(p) ✅ function dep_due_formula_correctPostconditions dep_due = liq_avail + outstanding - protocol_fee ✅ function dep_due_increases_with_depositsPreconditions Pool is solvent before and after deposit ✅ Postconditions dep_due_after = dep_due_before + deposit ✅ function dep_due_increases_with_outstandingPreconditions Pool is solvent before and after outstanding increase ✅ Postconditions dep_due_after = dep_due_before + outstanding_inc ✅ function dep_due_decreases_with_protocol_feePreconditions Pool is solvent before and after fee increase ✅ Postconditions dep_due_after = dep_due_before - fee_inc ✅ function claim_preserves_solvencyPreconditions mint_supply <= dep_due (depositor solvency) ✅ Preconditions claim_amt <= surplus (where surplus = dep_due - mint_supply) ✅ Preconditions claim_amt <= liq_avail ✅ Postconditions new_dep_due = dep_due - claim_amt ✅ Postconditions new_dep_due >= mint_supply ✅ function rebal_sol_out_preserves_dep_duePreconditions rebal_amt <= liq_avail ✅ Postconditions pool_acc_lamports decreases by rebal_amt ✅ Postconditions outstanding increases by rebal_amt ✅ Postconditions dep_due_after = dep_due_before (unchanged) ✅ function rebal_sol_inp_preserves_dep_duePreconditions rebal_amt <= outstanding ✅ Postconditions pool_acc_lamports increases by rebal_amt ✅ Postconditions outstanding decreases by rebal_amt ✅ Postconditions dep_due_after = dep_due_before (unchanged) ✅ /// is_pool_solvent <=> dep_due_checked returns Some#[kani::proof]fn is_pool_solvent_iff_dep_due_checked_some() { let x = pool(); kani::assume(is_pool_solvent(x)); assert!(x.0.dep_due_checked(x.1).is_some()); let y = pool(); kani::assume(y.0.dep_due_checked(y.1).is_some()); assert!(is_pool_solvent(y));} /// dep_due = liq_avail + outstanding - protocol_fee#[kani::proof]fn dep_due_formula_correct() { let (p, pool_acc_lamports) = solvent_pool(); let dep_due = p.dep_due_checked(pool_acc_lamports).unwrap(); let liq_avail = p.liq_avail_checked(pool_acc_lamports).unwrap(); assert_eq!(dep_due, liq_avail + p.outstanding() - p.protocol_fee());} /// depositing N lamports increases dep_due by exactly N#[kani::proof]fn dep_due_increases_with_deposits() { let (p, pool_acc_lamports) = solvent_pool(); let deposit: u64 = kani::any(); // ... overflow guards ... assert_eq!(dep_due_after, dep_due_before + deposit);} /// increasing outstanding by N increases dep_due by exactly N#[kani::proof]fn dep_due_increases_with_outstanding() { let (p, pool_acc_lamports) = solvent_pool(); let outstanding_inc: u64 = kani::any(); // ... overflow guards ... assert_eq!(dep_due_after, dep_due_before + outstanding_inc);} /// increasing protocol_fee by N decreases dep_due by exactly N#[kani::proof]fn dep_due_decreases_with_protocol_fee() { let (p, pool_acc_lamports) = solvent_pool(); let fee_inc: u64 = kani::any(); // ... overflow guards ... assert_eq!(dep_due_after + fee_inc, dep_due_before);} /// claiming <= surplus preserves dep_due >= mint_supply#[kani::proof]fn claim_preserves_solvency() { let (p, pool_acc_lamports) = solvent_pool(); let mint_supply: u64 = kani::any(); kani::assume(mint_supply <= dep_due); kani::assume(claim_amt <= surplus); kani::assume(claim_amt <= liq_avail); // ... assert_eq!(new_dep_due, dep_due - claim_amt); assert!(new_dep_due >= mint_supply);} /// rebalancing SOL out (lamports ↓, outstanding ↑) preserves dep_due#[kani::proof]fn rebal_sol_out_preserves_dep_due() { let (p, pool_acc_lamports) = solvent_pool(); let rebal_amt: u64 = kani::any(); kani::assume(rebal_amt <= liq_avail); // new_pool_acc_lamports = pool_acc_lamports - rebal_amt // new_outstanding = outstanding + rebal_amt assert_eq!(dep_due_after, dep_due_before);} /// rebalancing SOL in (lamports ↑, outstanding ↓) preserves dep_due#[kani::proof]fn rebal_sol_inp_preserves_dep_due() { let (p, pool_acc_lamports) = solvent_pool(); let rebal_amt: u64 = kani::any(); kani::assume(rebal_amt <= outstanding); // new_pool_acc_lamports = pool_acc_lamports + rebal_amt // new_outstanding = outstanding - rebal_amt assert_eq!(dep_due_after, dep_due_before);}[Appendix] Formal Verification: claim_holdings.rs
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Spec Overview for
core::rebal::claim_holding.rsFunctionality Details Verification Status function claim_holding_preserves_surplusPreconditions Pool has liq_avail = 0 (empty reserves) ✅ Preconditions Holding has non-zero outstanding, sol_value, balance ✅ Preconditions claim_lamports <= outstanding ✅ Postconditions outstanding_dec = claim_lamports - protocol_fee_lamports ✅ function claim_holding_protocol_fees_boundedPostconditions outstanding_dec = claim_lamports - protocol_fees ✅ function claim_holding_output_boundedPreconditions Valid holding with outstanding, sol_value, balance > 0 ✅ Postconditions output_tokens <= holding_balance ✅ function claim_holding_input_preservedPostconditions quote.qtys.amt().inp() = claim_lamports ✅ Postconditions quote.qtys.sol_value().inp() = claim_lamports ✅ /// surplus preserved: outstanding_dec = claim_lamports - protocol_fee_lamports#[kani::proof]fn claim_holding_preserves_surplus() { let (claim_lamports, args) = valid_claim_holding_args(); let result = quote_claim_holding(claim_lamports, &args); if let Ok(quote) = result { let protocol_fee_lamports = quote.protocol_fees.total(); let expected_outstanding_dec = (claim_lamports as i128) - (protocol_fee_lamports as i128); assert_eq!(quote.outstanding_dec, expected_outstanding_dec); }} /// protocol fees relationship: outstanding_dec = claim_lamports - protocol_fees#[kani::proof]fn claim_holding_protocol_fees_bounded() { let (claim_lamports, args) = valid_claim_holding_args(); let result = quote_claim_holding(claim_lamports, &args); if let Ok(quote) = result { let protocol_fee_lamports = quote.protocol_fees.total(); let expected = (claim_lamports as i128) - (protocol_fee_lamports as i128); assert_eq!(quote.outstanding_dec, expected); }} /// output tokens bounded by holding balance#[kani::proof]#[kani::unwind(2)]fn claim_holding_output_bounded() { let (claim_lamports, args) = valid_claim_holding_args(); let result = quote_claim_holding(claim_lamports, &args); if let Ok(quote) = result { assert!(*quote.qtys.amt().out() <= args.holding_balance); }} /// input amounts correctly preserved#[kani::proof]#[kani::unwind(2)]fn claim_holding_input_preserved() { let (claim_lamports, args) = valid_claim_holding_args(); let result = quote_claim_holding(claim_lamports, &args); if let Ok(quote) = result { assert_eq!(*quote.qtys.amt().inp(), claim_lamports); assert_eq!(*quote.qtys.sol_value().inp(), claim_lamports); }}Architecture review
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Mustafa Hasan
Architecture Summary: Sanctum SOLS Pool Program
Protocol
Permissionless, fractional-reserve wrapped SOL protocol on Solana. Each pool issues its own SOLS token (9 decimals, 1:1 SOL redeemable). Idle SOL reserves are deployed into yield-bearing LSTs by an authorized
rebalancer, subject to configurable reserve ratio requirements (RRR). Protocol earns fees on rebalance-in operations.
Codebase Structure
Crate Purpose core/Pure math, canonical types, invariants. No BPF code. jiminy/Onchain view layer: account parsing/validation, wraps corefor BPF.program/Deployed BPF program: instruction dispatch, handlers, CPI logic. test-utils/Mollusk SVM harness, mock accounts.
Key Accounts
- Pool — Central account and SOL vault (lamports held directly). PDA:
[sols_mint_addr]. Mint authority of its SOLS mint and owner of all yield-token ATAs. - Portfolio — Dynamic packed list of
Holdingentries (one per non-SOL token). PDA:[b"y", sols_mint_addr]. - Protocol — Singleton global state. PDA:
[b"a"]. Holds protocol authorities, fee params, and whitelisted SVC programs. - WsolBridge — Ephemeral wSOL intermediary per pool. PDA:
[b"x", sols_mint_addr].
Depositor due formula (core invariant — SOLS supply must never exceed this):
depositor_due = pool.lamports - rent_exempt_lamports - protocol_fee_lamports + outstanding_lamports
Authority Model
Protocol-level:
fee_controller— Set fee ratessvc_whitelist_auth— Manage SVC whitelistbeneficiary— Claim protocol fees (dual-sig required for transfer)
Per-pool (hierarchical):
admin→manager→rebalancer/rrr_controller
Key Mechanisms
Reserve Ratio Requirements (RRR):
rrr_floorandrrr_ceil(in Nanos) gate rebalance-out/in. Rebalance-in belowrrr_flooris permissionless — anyone can liquidate holdings to restore solvency.Flash Rebalancing:
StartRebal/EndRebalmust appear in the same tx.StartRebalappends aRebalAuxsegment to the Pool account;EndRebalvalidates outcome and truncates it. Instructions sysvar enforces ordering.Fees: Fixed fee (≤10% of principal) + performance fee (on SOL value appreciation) on rebalance-in. Accumulated in
pool.protocol_fee_lamports; claimed by minting new SOLS (not withdrawing SOL).SVC Integration: No internal price oracle. SOL values for holdings are computed via CPI to whitelisted SVC programs (Sanctum INF infrastructure) at rebalance time.
Alternative Redemption: When SOL reserves are exhausted,
ClaimHoldinglets users redeem SOLS for yield tokens directly.Swap(variant 2) extends this for cross-pool swaps.Rent Management: Pre-funded by transfers; excess reclaimed permissionlessly via
GarbageCollect/SyncRentPool. No rent payer in instruction accounts.
Instruction Surface (29 instructions)
Category Instructions User Mint, TransferThenMint, TransferWrappedThenMint, Claim, ClaimWrapped, BurnThenClaim, BurnThenClaimWrapped, Swap, ClaimHolding Pool Lifecycle Init, Close Pool Admin SetAdmin, SetManager, SetHolding, RemHolding, SetSolOutLossTol, SetRebalancer Rebalancing StartRebal, EndRebal RRR SetRRRController, SetRRR Protocol SetProtocolFeeController, SetProtocolFees, SetSvcWhitelistAuth, AddSvc, RemSvc, SetProtocolFeeBeneficiary, ClaimProtocolFees Maintenance SyncRentPool, GarbageCollect MintandClaimare permissionless by design — security relies entirely on thedepositor_duesurplus check.
Testing & Verification
- Integration tests via Mollusk SVM harness (BPF binary, includes automatic rent-exemption checks)
- Kani formal verification of core arithmetic (
depositor_due, Nanos, invariants) in thecorecrate
Notable Design Observations
Strengths: Clean core/program separation enables isolated verification. Flash rebalance pattern is well-scoped. Permissionless liquidation at
rrr_floorprevents insolvency. Kani formal verification is meaningful.Complexity / Risk Areas:
RebalAuxin-place account extension is non-standard; cleanup guarantees require careful validationdepositor_duespans four lamport fields — multiple sites for invariant violationsClaimHolding/ Swap variant 2 fee conversion uses potentially stalesol_value— precision-sensitiveMint/Claimpermissionless design puts full trust indepositor_duecomputation — high-value correctness target
Dead perm Computation on Same-Mint Flash Loan Path
Description
In
process_start_rebal,thepermvariable (of typeRebalSolInpRRArgs) is computed inside a conditional block that handles both same-mint flash loans and permissionless SOL-input rebalances. When the signer is not the pool's authorized rebalancer,permis set toRebalSolInpRRArgs::Permless, which carries reserve-ratio floor arguments intended to enforce that a permissionless rebalance does not push the pool's reserve ratio beyond the configured floor. However, perm is only ever consumed byquote_rebal_sol_inp_exact_outin the SOL-input rebalance branch (Sol → Other). Whenis_same_mint_flash_loanis true, the code takes an early path that computesrebal_aux_mindirectly and skips all quoting logic entirely meaningpermis computed but never read. The reserve-ratio constraint that Permless is supposed to enforce is effectively dead code on this path.Recommendation
Refactor to compute perm only where it is actually consumed (inside the SOL-input rebalance branch), and keep only the expected_signer derivation in the flash-loan conditional. This eliminates the dead code and makes the developer intent clear — that RR enforcement is intentionally not applied to same-mint flash loans because they cannot alter the reserve ratio.
Napstec Improvements: Undocumented Permissionless SOL Flash Loans via Same Mint Rebalance Path
Description
The
is_same_mint_flash_loancheck compares whether the input and output token indices are equal. When they match, any signer is accepted with no quote computation and no fee charged. AtEndRebal, the only validation is that the minimum value is preserved, meaning the pool lamports or token balance has not decreased.This was designed for the case where a user flash loans an LST back into the same LST, which is useful because
EndRebalrefreshes the holding'ssol_valuevia a CPI oracle call. However, the same code path also permits SOL to SOL flash loans. In this case, anyone can borrow SOL from the pool reserves, use it for arbitrage or other operations within the same transaction, and return it with zero fees. SOL has nosol_valueto refresh, so the flash loan produces no side effect for the protocol.The
DESIGN.mddocumentation describes this feature as "same LST into the same LST" and does not mention SOL flash loans. The client has confirmed this behavior is intentional and was simply not reflected in the documentation.Recommendation
Update
DESIGN.mdto explicitly document that same mint flash loans include the SOL to SOL case, noting that this is permissionless and fee free by design. Alternatively, if the team decides free SOL flash loans are not desirable, add a check in the same mint flash loan path that rejects the case where both input and output areTokenT::Sol, restricting the feature to LST to LST only.[Appendix] Formal Verification: swap_holding.rs
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Spec Overview for
core::rebal::swap_holding.rsFunctionality Details Verification Status function swap_holding_fee_decompositionPreconditions Valid ClaimHoldingQuote with sol_value_out > 0 ✅ Preconditions Valid deposit_fee Nanos (<= 1B), tight bounds (values <= 10) ✅ Postconditions deposit_fee_lamports + qtys.out() = sol_value.out (AftFee invariant) ✅ function swap_holding_out_bounded_by_sol_valuePreconditions Valid ClaimHoldingQuote, deposit_fee Nanos ✅ Postconditions qtys.out() <= sol_value.out (deposit fee is always >= 0) ✅ function swap_holding_input_preservedPreconditions Valid ClaimHoldingQuote with claim_inp > 0 ✅ Postconditions qtys.inp() = claim_inp (input token amount forwarded from claim) ✅ function swap_holding_holding_fields_preservedPreconditions Valid ClaimHoldingQuote with claim_out, sol_value_out ✅ Postconditions holding.amt() = claim's amt.out() ✅ Postconditions holding.sol_value() = claim's sol_value.out() ✅ function swap_other_end_to_end_surplusPreconditions Valid inp holding with outstanding, sol_value, balance (tight bounds <= 10) ✅ Preconditions Inp pool has liq_avail = 0, zero fees for tractability ✅ Preconditions Out pool is solvent: mint_supply <= dep_due ✅ Postconditions Out pool surplus exactly preserved after outstanding += delta, mint_supply += delta ✅ Postconditions Inp pool: outstanding_dec = claim_lamports - pf_total (surplus preservation relationship) ✅ /// Prove: deposit_fee + out == sol_value.out from claim (AftFee invariant)./// This ensures no lamports are created or destroyed by the fee split.#[kani::proof]#[kani::unwind(2)]fn swap_holding_fee_decomposition() { let claim_inp: u64 = kani::any(); let claim_out: u64 = kani::any(); let sol_value_inp: u64 = kani::any(); let sol_value_out: u64 = kani::any(); let outstanding_dec: i128 = kani::any(); let pf_fixed: u64 = kani::any(); let pf_perf: u64 = kani::any(); let deposit_fee_nanos: u32 = kani::any(); kani::assume(claim_inp > 0 && claim_inp <= 10); kani::assume(claim_out <= 10); kani::assume(sol_value_inp > 0 && sol_value_inp <= 10); kani::assume(sol_value_out > 0 && sol_value_out <= 10); kani::assume(outstanding_dec >= -10 && outstanding_dec <= 10); kani::assume(pf_fixed <= 10); kani::assume(pf_perf <= 10); kani::assume(deposit_fee_nanos <= 1_000_000_000); let chq = ClaimHoldingQuote { qtys: RebalInpOut::const_from_destr(RebalQtysDestr { amt: InpOut::const_from_destr(InpOutDestr { inp: claim_inp, out: claim_out, }), sol_value: InpOut::const_from_destr(InpOutDestr { inp: sol_value_inp, out: sol_value_out, }), }), outstanding_dec, protocol_fees: ProtocolV1FeeLamports::const_from_destr(ProtocolV1FeesDestr { fixed: pf_fixed, perf: pf_perf, }), }; let deposit_fee = Nanos::new(deposit_fee_nanos).unwrap(); // Pool state for RRArgs let rent_exempt: u64 = kani::any(); let pool_outstanding: u64 = kani::any(); let pool_pf: u64 = kani::any(); let pool_acc: u64 = kani::any(); let rrr_nanos: u32 = kani::any(); kani::assume(rent_exempt <= pool_acc); kani::assume(pool_acc <= 100); kani::assume(pool_outstanding <= 100); kani::assume(pool_pf <= 100); kani::assume(rrr_nanos <= 1_000_000_000); let rr = RRArgs { rrr_limit: Nanos::new(rrr_nanos).unwrap(), pool_lamports: PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt, outstanding: pool_outstanding, protocol_fee: pool_pf, }), pool_acc_lamports: pool_acc, }; let result = swap_holding_quote_from_claim(&chq, deposit_fee, &rr); if let Ok(q) = result { assert_eq!( q.deposit_fee_lamports + *q.qtys.out(), sol_value_out, "fee + out must equal sol_value.out" ); }} /// Prove: output amount (after deposit fee) is bounded by the claim's sol_value.out./// Ensures deposit fee is always non-negative.#[kani::proof]#[kani::unwind(2)]fn swap_holding_out_bounded_by_sol_value() { let sol_value_out: u64 = kani::any(); let deposit_fee_nanos: u32 = kani::any(); kani::assume(sol_value_out > 0 && sol_value_out <= 10); kani::assume(deposit_fee_nanos <= 1_000_000_000); let chq = ClaimHoldingQuote { qtys: RebalInpOut::const_from_destr(RebalQtysDestr { amt: InpOut::const_from_destr(InpOutDestr { inp: 5, out: 5 }), sol_value: InpOut::const_from_destr(InpOutDestr { inp: 5, out: sol_value_out, }), }), outstanding_dec: 5, protocol_fees: ProtocolV1FeeLamports::const_from_destr(ProtocolV1FeesDestr { fixed: 0, perf: 0, }), }; let deposit_fee = Nanos::new(deposit_fee_nanos).unwrap(); let rr = RRArgs { rrr_limit: Nanos::new(0).unwrap(), pool_lamports: PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: 10, outstanding: 0, protocol_fee: 0, }), pool_acc_lamports: 50, }; let result = swap_holding_quote_from_claim(&chq, deposit_fee, &rr); if let Ok(q) = result { assert!( *q.qtys.out() <= sol_value_out, "out must not exceed sol_value.out (deposit fee >= 0)" ); }} /// Prove: the input token amount is preserved from the claim quote.#[kani::proof]#[kani::unwind(2)]fn swap_holding_input_preserved() { let claim_inp: u64 = kani::any(); let sol_value_out: u64 = kani::any(); let deposit_fee_nanos: u32 = kani::any(); kani::assume(claim_inp > 0 && claim_inp <= 10); kani::assume(sol_value_out > 0 && sol_value_out <= 10); kani::assume(deposit_fee_nanos <= 1_000_000_000); let chq = ClaimHoldingQuote { qtys: RebalInpOut::const_from_destr(RebalQtysDestr { amt: InpOut::const_from_destr(InpOutDestr { inp: claim_inp, out: 5, }), sol_value: InpOut::const_from_destr(InpOutDestr { inp: claim_inp, out: sol_value_out, }), }), outstanding_dec: 5, protocol_fees: ProtocolV1FeeLamports::const_from_destr(ProtocolV1FeesDestr { fixed: 0, perf: 0, }), }; let deposit_fee = Nanos::new(deposit_fee_nanos).unwrap(); let rr = RRArgs { rrr_limit: Nanos::new(0).unwrap(), pool_lamports: PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: 10, outstanding: 0, protocol_fee: 0, }), pool_acc_lamports: 50, }; let result = swap_holding_quote_from_claim(&chq, deposit_fee, &rr); if let Ok(q) = result { assert_eq!( *q.qtys.inp(), claim_inp, "input amount must be preserved from claim" ); }} /// Prove: holding sol_value and amt are preserved from the claim quote.#[kani::proof]#[kani::unwind(2)]fn swap_holding_holding_fields_preserved() { let claim_out: u64 = kani::any(); let sol_value_out: u64 = kani::any(); let deposit_fee_nanos: u32 = kani::any(); kani::assume(claim_out > 0 && claim_out <= 10); kani::assume(sol_value_out > 0 && sol_value_out <= 10); kani::assume(deposit_fee_nanos <= 1_000_000_000); let chq = ClaimHoldingQuote { qtys: RebalInpOut::const_from_destr(RebalQtysDestr { amt: InpOut::const_from_destr(InpOutDestr { inp: 5, out: claim_out, }), sol_value: InpOut::const_from_destr(InpOutDestr { inp: 5, out: sol_value_out, }), }), outstanding_dec: 5, protocol_fees: ProtocolV1FeeLamports::const_from_destr(ProtocolV1FeesDestr { fixed: 0, perf: 0, }), }; let deposit_fee = Nanos::new(deposit_fee_nanos).unwrap(); let rr = RRArgs { rrr_limit: Nanos::new(0).unwrap(), pool_lamports: PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: 10, outstanding: 0, protocol_fee: 0, }), pool_acc_lamports: 50, }; let result = swap_holding_quote_from_claim(&chq, deposit_fee, &rr); if let Ok(q) = result { assert_eq!( *q.holding.amt(), claim_out, "holding.amt must be claim's amt.out" ); assert_eq!( *q.holding.sol_value(), sol_value_out, "holding.sol_value must be claim's sol_value.out" ); }} /// End-to-end swap_other: run quote_claim_holding → swap_holding_quote_from_claim,/// then verify both inp and out pool surplus is preserved.#[kani::proof]#[kani::unwind(2)]fn swap_other_end_to_end_surplus() { use crate::accounts::{ PoolLiqLamportVals, PoolLiqLamportsDestr, ProtocolV1FeeRatios, }; use crate::typedefs::HoldingV1LamportVals; use crate::typedefs::HoldingV1LamportsDestr; use crate::utils::protocol_fee_ratio; // --- Inp pool holding state --- let outstanding: u64 = kani::any(); let sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let claim_lamports: u64 = kani::any(); kani::assume(outstanding > 0 && outstanding <= 10); kani::assume(sol_value > 0 && sol_value <= 10); kani::assume(holding_balance > 0 && holding_balance <= 10); kani::assume(claim_lamports > 0 && claim_lamports <= outstanding); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding, sol_value, }); let pool_liq = PoolLiqLamportVals::const_from_destr(PoolLiqLamportsDestr { total: 5, rent_exempt: 5, // liq_avail = 0 }); let protocol_fees = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(0).unwrap()), perf: protocol_fee_ratio(Nanos::new(0).unwrap()), }); let inp_args = QuoteClaimHoldingArgs { holding, holding_balance, protocol_fees, pool: pool_liq, }; // Step 1: claim_holding let chq = match quote_claim_holding(claim_lamports, &inp_args) { Ok(x) => x, Err(_) => return, }; // --- Out pool state --- let out_rent: u64 = kani::any(); let out_outstanding: u64 = kani::any(); let out_pf: u64 = kani::any(); let out_acc: u64 = kani::any(); let out_mint_supply: u64 = kani::any(); let deposit_fee_nanos: u32 = kani::any(); kani::assume(out_rent > 0 && out_rent <= 10); kani::assume(out_acc >= out_rent && out_acc <= 10); kani::assume(out_outstanding <= 10); kani::assume(out_pf <= 10); kani::assume(deposit_fee_nanos <= 1_000_000_000); let out_pool = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: out_rent, outstanding: out_outstanding, protocol_fee: out_pf, }); // Out pool must be solvent before let out_dep_due_before = match out_pool.dep_due_checked(out_acc) { Some(x) => x, None => return, }; kani::assume(out_mint_supply <= out_dep_due_before); let deposit_fee = Nanos::new(deposit_fee_nanos).unwrap(); let rr = RRArgs { rrr_limit: Nanos::new(0).unwrap(), // no reserve ratio floor pool_lamports: out_pool, pool_acc_lamports: out_acc, }; // Step 2: swap_holding_quote_from_claim let shq = match swap_holding_quote_from_claim(&chq, deposit_fee, &rr) { Ok(x) => x, Err(_) => return, }; let delta = *shq.qtys.out(); // amount minted to user = outstanding increment // --- Verify out pool surplus preserved --- if let Some(new_outstanding) = out_outstanding.checked_add(delta) { let new_out_pool = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: out_rent, outstanding: new_outstanding, protocol_fee: out_pf, }); if let Some(new_dep_due) = new_out_pool.dep_due_checked(out_acc) { let new_mint_supply = match out_mint_supply.checked_add(delta) { Some(x) => x, None => return, }; // Surplus is exactly preserved assert_eq!( new_dep_due.saturating_sub(new_mint_supply), out_dep_due_before.saturating_sub(out_mint_supply), "out pool surplus must be preserved after swap_other" ); } } // --- Verify inp pool surplus preserved --- // claim_holding: outstanding_dec = claim_lamports - pf_total (with zero fees, outstanding_dec = claim_lamports) // dep_due_change = -claim_lamports, mint_supply_change = -claim_lamports // surplus_change = 0 let pf_total = match chq.protocol_fees.total_checked() { Some(x) => x, None => return, }; let expected_outstanding_dec = (claim_lamports as i128) - (pf_total as i128); assert_eq!( chq.outstanding_dec, expected_outstanding_dec, "inp pool: outstanding_dec must equal claim_lamports - pf_total" );}[Appendix] Formal Verification: sol_out.rs & sol_inp.rs
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jay
Spec Overview for
core::rebal::sol_out.rsandcore::rebal::sol_inp.rsSOL Out (
quote_rebal_sol_out_exact_out_sol_val)Functionality Details Verification Status function sol_out_outstanding_equals_outPreconditions out > 0, valid pool state with rent_exempt <= pool_acc_lamports ✅ Preconditions Bounds: out <= 10M, pool_acc_lamports <= 10M ✅ Postconditions q.outstanding = out ✅ Postconditions q.sol_value.out() = out ✅ function sol_out_inp_bounded_by_outPreconditions out > 0, pool_rent_exempt <= pool_total ✅ Preconditions sol_out_loss_tol_raw <= 10M (max ~1% loss tolerance) ✅ Postconditions sol_value.inp() <= sol_value.out() (loss tolerance means pool accepts less LST value) ✅ function sol_out_preserves_dep_duePreconditions Valid pool with rent_exempt, outstanding, protocol_fee ✅ Preconditions out > 0, sol_out_loss_tol valid ✅ Postconditions After rebalance: pool_acc_lamports -= out, outstanding += out ✅ Postconditions dep_due_after = dep_due_before (dep_due is preserved) ✅ SOL Inp (
quote_rebal_sol_inp_exact_out_inner)Functionality Details Verification Status function sol_inp_inp_is_returning_plus_feesPreconditions out > 0, out <= holding_balance, valid holding and fee ratios ✅ Preconditions Bounds: values <= 10M, fee nanos < 1B ✅ Postconditions amt.inp() = lamports_returning + protocol_fees_total ✅ function sol_inp_outstanding_boundedPreconditions out > 0, out <= holding_balance ✅ Preconditions Tight bounds (values <= 10) due to Ceil::apply u128 arithmetic ✅ Postconditions lamports_returning <= holding.outstanding ✅ function sol_inp_preserves_dep_duePreconditions Valid pool state, valid holding with tight bounds ✅ Preconditions rent_exempt <= pool_acc, pool values <= 100 ✅ Postconditions pool_acc_lamports += inp, outstanding -= returning, protocol_fee += pf ✅ Postconditions dep_due_after = dep_due_before (dep_due is preserved) ✅ function sol_inp_no_perf_fee_when_no_gainsPreconditions sol_value <= outstanding (holding has not appreciated) ✅ Preconditions Valid holding, fee ratios, and out <= holding_balance ✅ Postconditions protocol_fees.perf() = 0 (no performance fee charged) ✅ function sol_inp_sol_value_inp_equals_amt_inpPreconditions Valid holding, fee ratios, and out <= holding_balance ✅ Postconditions sol_value.inp() = amt.inp() (SOL input: both are the same) ✅ // ============================================================// SOL Out proofs// ============================================================ /// Prove: outstanding always equals out AND sol_value.out == out./// Tests on the full outer function (includes verify_rebal_above_ceil).#[kani::proof]#[kani::unwind(2)]fn sol_out_outstanding_equals_out() { let out: u64 = kani::any(); let sol_out_loss_tol_raw: u32 = kani::any(); let rent_exempt: u64 = kani::any(); let outstanding: u64 = kani::any(); let protocol_fee: u64 = kani::any(); let pool_acc_lamports: u64 = kani::any(); let rrr_ceil_raw: u32 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(rent_exempt <= pool_acc_lamports); kani::assume(pool_acc_lamports <= 10_000_000); kani::assume(outstanding <= 10_000_000); kani::assume(protocol_fee <= 10_000_000); kani::assume(sol_out_loss_tol_raw <= 10_000_000); kani::assume(rrr_ceil_raw <= 1_000_000_000); let sol_out_loss_tol = Nanos::new(sol_out_loss_tol_raw).unwrap(); let rrr_limit = Nanos::new(rrr_ceil_raw).unwrap(); let pool_lamports = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt, outstanding, protocol_fee, }); let rr = RRArgs { rrr_limit, pool_lamports, pool_acc_lamports, }; let result = quote_rebal_sol_out_exact_out_sol_val(out, sol_out_loss_tol, &rr); if let Ok(q) = result { assert_eq!(q.outstanding, out); assert_eq!(*q.sol_value.out(), out); }} /// Prove: inp_sol_val <= out (loss tolerance means pool accepts less LST value)./// Tests the inner function directly for tractability.#[kani::proof]#[kani::unwind(2)]fn sol_out_inp_bounded_by_out() { let out: u64 = kani::any(); let pool_total: u64 = kani::any(); let pool_rent_exempt: u64 = kani::any(); let sol_out_loss_tol_raw: u32 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(pool_rent_exempt <= pool_total); kani::assume(pool_total <= 10_000_000); kani::assume(sol_out_loss_tol_raw <= 10_000_000); let pool = PoolLiqLamportVals::const_from_destr(PoolLiqLamportsDestr { total: pool_total, rent_exempt: pool_rent_exempt, }); let sol_out_loss_tol = Nanos::new(sol_out_loss_tol_raw).unwrap(); let result = quote_rebal_sol_out_exact_out_sol_val_inner(out, &pool, sol_out_loss_tol); if let Ok(q) = result { assert!( *q.sol_value.inp() <= *q.sol_value.out(), "inp sol value must not exceed out sol value" ); }} /// Prove: dep_due is preserved after a sol_out rebalance./// pool_acc_lamports -= out, pool.outstanding += out => dep_due unchanged.#[kani::proof]#[kani::unwind(2)]fn sol_out_preserves_dep_due() { let out: u64 = kani::any(); let pool_total: u64 = kani::any(); let pool_rent_exempt: u64 = kani::any(); let sol_out_loss_tol_raw: u32 = kani::any(); let pool_outstanding: u64 = kani::any(); let pool_protocol_fee: u64 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(pool_rent_exempt <= pool_total); kani::assume(pool_total <= 10_000_000); kani::assume(pool_outstanding <= 10_000_000); kani::assume(pool_protocol_fee <= 10_000_000); kani::assume(sol_out_loss_tol_raw <= 10_000_000); let pool = PoolLiqLamportVals::const_from_destr(PoolLiqLamportsDestr { total: pool_total, rent_exempt: pool_rent_exempt, }); let sol_out_loss_tol = Nanos::new(sol_out_loss_tol_raw).unwrap(); let result = quote_rebal_sol_out_exact_out_sol_val_inner(out, &pool, sol_out_loss_tol); if let Ok(q) = result { let pool_lamports = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: pool_rent_exempt, outstanding: pool_outstanding, protocol_fee: pool_protocol_fee, }); let dep_due = match pool_lamports.dep_due_checked(pool_total) { Some(x) => x, None => return, }; let new_pool_acc = match pool_total.checked_sub(*q.sol_value.out()) { Some(x) => x, None => return, }; let new_outstanding = match pool_outstanding.checked_add(q.outstanding) { Some(x) => x, None => return, }; let new_pool_lamports = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt: pool_rent_exempt, outstanding: new_outstanding, protocol_fee: pool_protocol_fee, }); let new_dep_due = match new_pool_lamports.dep_due_checked(new_pool_acc) { Some(x) => x, None => return, }; assert_eq!(new_dep_due, dep_due, "dep_due must be preserved"); }} // ============================================================// SOL Inp proofs// ============================================================ /// Prove: inp == outstanding (lamports_returning) + protocol_fees./// This is critical: the SOL entering the pool must exactly equal/// the outstanding being settled plus the protocol's cut.#[kani::proof]#[kani::unwind(2)]fn sol_inp_inp_is_returning_plus_fees() { let out: u64 = kani::any(); let outstanding: u64 = kani::any(); let sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let fixed_fee_nanos: u32 = kani::any(); let perf_fee_nanos: u32 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(holding_balance > 0 && holding_balance <= 10_000_000); kani::assume(out <= holding_balance); kani::assume(outstanding > 0 && outstanding <= 10_000_000); kani::assume(sol_value > 0 && sol_value <= 10_000_000); kani::assume(fixed_fee_nanos < 1_000_000_000); kani::assume(perf_fee_nanos < 1_000_000_000); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding, sol_value, }); let pf = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(fixed_fee_nanos).unwrap()), perf: protocol_fee_ratio(Nanos::new(perf_fee_nanos).unwrap()), }); let result = quote_rebal_sol_inp_exact_out_inner(out, &holding, holding_balance, &pf); if let Ok(q) = result { let total_pf = q.protocol_fees.total_checked().unwrap(); assert_eq!( *q.qtys.amt().inp(), q.outstanding + total_pf, "inp must equal lamports_returning + protocol_fees" ); }} /// Prove: lamports_returning (outstanding) is bounded by holding.outstanding./// Since out <= holding_balance, ceil(out/holding_balance * outstanding) <= outstanding.#[kani::proof]#[kani::unwind(2)]fn sol_inp_outstanding_bounded() { let out: u64 = kani::any(); let outstanding: u64 = kani::any(); let sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let fixed_fee_nanos: u32 = kani::any(); let perf_fee_nanos: u32 = kani::any(); kani::assume(out > 0 && out <= 10); kani::assume(holding_balance > 0 && holding_balance <= 10); kani::assume(out <= holding_balance); kani::assume(outstanding > 0 && outstanding <= 10); kani::assume(sol_value > 0 && sol_value <= 10); kani::assume(fixed_fee_nanos < 1_000_000_000); kani::assume(perf_fee_nanos < 1_000_000_000); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding, sol_value, }); let pf = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(fixed_fee_nanos).unwrap()), perf: protocol_fee_ratio(Nanos::new(perf_fee_nanos).unwrap()), }); let result = quote_rebal_sol_inp_exact_out_inner(out, &holding, holding_balance, &pf); if let Ok(q) = result { assert!( q.outstanding <= outstanding, "lamports_returning must not exceed holding outstanding" ); }} /// Prove: dep_due is preserved after sol_inp rebalance./// pool_acc_lamports += inp, pool.outstanding -= returning, pool.protocol_fee += pf/// => dep_due unchanged.#[kani::proof]#[kani::unwind(2)]fn sol_inp_preserves_dep_due() { let out: u64 = kani::any(); let h_outstanding: u64 = kani::any(); let h_sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let fixed_fee_nanos: u32 = kani::any(); let perf_fee_nanos: u32 = kani::any(); kani::assume(out > 0 && out <= 10); kani::assume(holding_balance > 0 && holding_balance <= 10); kani::assume(out <= holding_balance); kani::assume(h_outstanding > 0 && h_outstanding <= 10); kani::assume(h_sol_value > 0 && h_sol_value <= 10); kani::assume(fixed_fee_nanos < 1_000_000_000); kani::assume(perf_fee_nanos < 1_000_000_000); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding: h_outstanding, sol_value: h_sol_value, }); let pf_ratios = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(fixed_fee_nanos).unwrap()), perf: protocol_fee_ratio(Nanos::new(perf_fee_nanos).unwrap()), }); let rent_exempt: u64 = kani::any(); let pool_outstanding: u64 = kani::any(); let pool_pf: u64 = kani::any(); let pool_acc: u64 = kani::any(); kani::assume(rent_exempt <= pool_acc); kani::assume(pool_acc <= 100); kani::assume(pool_outstanding <= 100); kani::assume(pool_pf <= 100); let pool_lamports = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt, outstanding: pool_outstanding, protocol_fee: pool_pf, }); let dep_due = match pool_lamports.dep_due_checked(pool_acc) { Some(x) => x, None => return, }; let result = quote_rebal_sol_inp_exact_out_inner(out, &holding, holding_balance, &pf_ratios); if let Ok(q) = result { let total_pf = match q.protocol_fees.total_checked() { Some(x) => x, None => return, }; let new_pool_acc = match pool_acc.checked_add(*q.qtys.amt().inp()) { Some(x) => x, None => return, }; let new_outstanding = match pool_outstanding.checked_sub(q.outstanding) { Some(x) => x, None => return, }; let new_pf = match pool_pf.checked_add(total_pf) { Some(x) => x, None => return, }; let new_pool_lamports = PoolV1LamportVals::const_from_destr(PoolV1LamportsDestr { rent_exempt, outstanding: new_outstanding, protocol_fee: new_pf, }); let new_dep_due = match new_pool_lamports.dep_due_checked(new_pool_acc) { Some(x) => x, None => return, }; assert_eq!(new_dep_due, dep_due, "dep_due must be preserved"); }} /// Prove: when sol_value <= outstanding (no gains / depreciation),/// performance fee must be 0. Only fixed fee should apply.#[kani::proof]#[kani::unwind(2)]fn sol_inp_no_perf_fee_when_no_gains() { let out: u64 = kani::any(); let outstanding: u64 = kani::any(); let sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let fixed_fee_nanos: u32 = kani::any(); let perf_fee_nanos: u32 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(holding_balance > 0 && holding_balance <= 10_000_000); kani::assume(out <= holding_balance); kani::assume(outstanding > 0 && outstanding <= 10_000_000); kani::assume(sol_value > 0 && sol_value <= 10_000_000); kani::assume(sol_value <= outstanding); kani::assume(fixed_fee_nanos < 1_000_000_000); kani::assume(perf_fee_nanos < 1_000_000_000); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding, sol_value, }); let pf = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(fixed_fee_nanos).unwrap()), perf: protocol_fee_ratio(Nanos::new(perf_fee_nanos).unwrap()), }); let result = quote_rebal_sol_inp_exact_out_inner(out, &holding, holding_balance, &pf); if let Ok(q) = result { assert_eq!( *q.protocol_fees.perf(), 0, "perf fee must be 0 when sol_value <= outstanding" ); }} /// Prove: sol_value.inp equals amt.inp (for SOL input, both are the same).#[kani::proof]#[kani::unwind(2)]fn sol_inp_sol_value_inp_equals_amt_inp() { let out: u64 = kani::any(); let outstanding: u64 = kani::any(); let sol_value: u64 = kani::any(); let holding_balance: u64 = kani::any(); let fixed_fee_nanos: u32 = kani::any(); let perf_fee_nanos: u32 = kani::any(); kani::assume(out > 0 && out <= 10_000_000); kani::assume(holding_balance > 0 && holding_balance <= 10_000_000); kani::assume(out <= holding_balance); kani::assume(outstanding > 0 && outstanding <= 10_000_000); kani::assume(sol_value > 0 && sol_value <= 10_000_000); kani::assume(fixed_fee_nanos < 1_000_000_000); kani::assume(perf_fee_nanos < 1_000_000_000); let holding = HoldingV1LamportVals::const_from_destr(HoldingV1LamportsDestr { outstanding, sol_value, }); let pf = ProtocolV1FeeRatios::const_from_destr(ProtocolV1FeesDestr { fixed: protocol_fee_ratio(Nanos::new(fixed_fee_nanos).unwrap()), perf: protocol_fee_ratio(Nanos::new(perf_fee_nanos).unwrap()), }); let result = quote_rebal_sol_inp_exact_out_inner(out, &holding, holding_balance, &pf); if let Ok(q) = result { assert_eq!( *q.qtys.sol_value().inp(), *q.qtys.amt().inp(), "sol_value.inp must equal amt.inp for SOL input" ); }}