Sanctum

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

  1. 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_bridge account 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 supply wsol_bridge as 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_bridge is a PDA and revert otherwise.

  2. Stale sol_value Base in Rebalance Output Entry Update

    State

    Fixed

    PR #47

    Severity

    Severity: High

    Likelihood: Medium

    ×

    Impact: High

    Submitted by

    Jay


    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))?,);
  3. 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 therefore inp_ata == out_ata). In this self-swap case, the instruction routes through ClaimHolding with out set 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 mints qtys.out SOLS 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

    1. process_swap_other() computes is_self_swap solely from the holding ATA equality and still proceeds to call process_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)
    1. process_claim_holding explicitly treats out == holding_ata as “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())?);}
    1. In the deposit leg, process_deposit_holding() also skips incrementing sol_value on 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

    1. An attacker spots a pool with low SOL reserves and an appreciated holding token
    2. The attacker sends a swap instruction with the input pool equal to the output pool and the appreciated holding token
    3. The program burns the raw_amt of SOLS supplied by the attacker
    4. The computed quote is larger than the amount burned since the holding has appreciated
    5. qtys.out tokens 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

  1. 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, the MINTinstruction 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 call MINT to place 1 SOLS into their own token account, failing the MintNotEmpty. The admin has no recourse, MINT has 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, while CLAIMand CLAIM_WRAPPED remain open so existing holders can exit. Once supply reaches zero the admin can proceed with CLOSE unimpeded. 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.

  2. Token-22 Extension Incompatibility in Holding Mint Validation

    State

    Fixed

    PR #50

    Severity

    Severity: Medium

    Likelihood: Medium

    ×

    Impact: High

    Submitted by

    Jay


    Description

    set_holding permits 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_holdingand rem_holding CPIs 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 each ClaimHoldingcall, 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.

  3. 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 Ratio from 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_floor that has a holding token whose outstanding == 0 and actual balance is > 0. When process_start_rebal() is called with SOL as inp() and TokenT::Other as out(), quote_rebal_sol_inp_exact_out() gets called, eventually calling calc_protocol_fees() with lamports_returning as zero since it is derived from holding.outstanding(), which is currently zero. As a result, the returned quote will have its inp() = 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.

  4. 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.

  5. Lamport Griefing Attack Blocks ClaimHolding Emergency Exit

    State

    Fixed

    PR #54

    Severity

    Severity: Medium

    Likelihood: Medium

    ×

    Impact: Medium

    Submitted by

    Jay


    Description

    The quote_claim_holdingfunction requires liq_avail to be exactly 0 for ClaimHolding to execute. ClaimHolding is 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_avail equals pool_acc_lamportsminus rent_exempt, anyone can send 1 lamport to the pool account to make liq_availgreater than 0, which blocks all ClaimHolding calls. The on chain SOL transfer requires no program interaction and cannot be blocked by the program.

    Attack Scenario:

    1. Pool has liq_avail equal to 0 because all SOL has been delegated to holdings via rebalancing.
    2. User burns X SOLS tokens, creating surplus of X for their ClaimHolding call.
    3. Griefer sends 1 lamport to the pool account, making liq_avail equal to 1.
    4. User's ClaimHolding reverts because liq_avail is greater than 0.
    5. Griefer repeats at a cost of approximately 1 lamport per block, indefinitely trapping users.

    Recommendation

    Modify the Claim instruction in claim.rs so 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, restoring liq_availto 0, and then ClaimHolding executes 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.

Low Risk2 findings

  1. 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 via mint_of() but does not check for a freeze authority. This is inconsistent with init.rs, which explicitly rejects the SOLS mint if freeze_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());}
  2. 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 ClaimHoldingQuote produced 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

  1. Incorrect IS_WRITER Constant in TWTM_IX_IS_SIGNER

    State

    Fixed

    PR #44

    Severity

    Severity: Informational

    Submitted by

    Jay


    Description

    The TWTM_IX_IS_SIGNERconstant uses TOKEN_SYS_PROG_IS_WRITER for its progs field, where it should use TOKEN_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_WRITER andTOKEN_SYS_PROG_IS_SIGNER are defined as TokenSysProgAccFlags::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 uses TOKEN_SYS_PROG_IS_SIGNER in its ownTTM_IX_IS_SIGNERconstant, confirming the established pattern.

    Recommendation

    Replace TOKEN_SYS_PROG_IS_WRITERwith TOKEN_SYS_PROG_IS_SIGNER in TWTM_IX_IS_SIGNER, and add TOKEN_SYS_PROG_IS_SIGNER to 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};
  2. Endianness Mismatch in RebalAuxData::from_pked

    State

    Fixed

    PR #44

    Severity

    Severity: Informational

    Submitted by

    Jay


    Description

    from_pkedand into_pkedform an asymmetric round-trip forinp_idx. into_pkedserialises with to_le_bytes but from_pked deserialises with from_be_bytes, corrupting any non-zero,non-u32::MAX index on round-trip. The bug is currently latent, no call site for RebalAuxData::from_pked exists and the onchain path bypasses it entirely via direct pointer casts, but any off-chain client or indexer that calls from_pked on a parsed RebalAuxPkedwould receive a garbageinp_idx.

    Recommendation

    Replace from_be_bytes withfrom_le_bytesto restore symmetry withinto_pked:

    inp_idx: u32::from_le_bytes(inp_idx),

  3. 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
  4. [Appendix] Formal Verification: mode.rs

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Jay


    Spec Overview for core::accounts::mod.rs

    FunctionalityDetailsVerification Status
    function is_pool_solvent_iff_dep_due_checked_some
    Postconditionsis_pool_solvent(p) ⟹ dep_due_checked(p).is_some()
    Postconditionsdep_due_checked(p).is_some() ⟹ is_pool_solvent(p)
    function dep_due_formula_correct
    Postconditionsdep_due = liq_avail + outstanding - protocol_fee
    function dep_due_increases_with_deposits
    PreconditionsPool is solvent before and after deposit
    Postconditionsdep_due_after = dep_due_before + deposit
    function dep_due_increases_with_outstanding
    PreconditionsPool is solvent before and after outstanding increase
    Postconditionsdep_due_after = dep_due_before + outstanding_inc
    function dep_due_decreases_with_protocol_fee
    PreconditionsPool is solvent before and after fee increase
    Postconditionsdep_due_after = dep_due_before - fee_inc
    function claim_preserves_solvency
    Preconditionsmint_supply <= dep_due (depositor solvency)
    Preconditionsclaim_amt <= surplus (where surplus = dep_due - mint_supply)
    Preconditionsclaim_amt <= liq_avail
    Postconditionsnew_dep_due = dep_due - claim_amt
    Postconditionsnew_dep_due >= mint_supply
    function rebal_sol_out_preserves_dep_due
    Preconditionsrebal_amt <= liq_avail
    Postconditionspool_acc_lamports decreases by rebal_amt
    Postconditionsoutstanding increases by rebal_amt
    Postconditionsdep_due_after = dep_due_before (unchanged)
    function rebal_sol_inp_preserves_dep_due
    Preconditionsrebal_amt <= outstanding
    Postconditionspool_acc_lamports increases by rebal_amt
    Postconditionsoutstanding decreases by rebal_amt
    Postconditionsdep_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);}
  5. [Appendix] Formal Verification: claim_holdings.rs

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Jay


    Spec Overview for core::rebal::claim_holding.rs

    FunctionalityDetailsVerification Status
    function claim_holding_preserves_surplus
    PreconditionsPool has liq_avail = 0 (empty reserves)
    PreconditionsHolding has non-zero outstanding, sol_value, balance
    Preconditionsclaim_lamports <= outstanding
    Postconditionsoutstanding_dec = claim_lamports - protocol_fee_lamports
    function claim_holding_protocol_fees_bounded
    Postconditionsoutstanding_dec = claim_lamports - protocol_fees
    function claim_holding_output_bounded
    PreconditionsValid holding with outstanding, sol_value, balance > 0
    Postconditionsoutput_tokens <= holding_balance
    function claim_holding_input_preserved
    Postconditionsquote.qtys.amt().inp() = claim_lamports
    Postconditionsquote.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);    }}
  6. 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

    CratePurpose
    core/Pure math, canonical types, invariants. No BPF code.
    jiminy/Onchain view layer: account parsing/validation, wraps core for 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 Holding entries (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 rates
    • svc_whitelist_auth — Manage SVC whitelist
    • beneficiary — Claim protocol fees (dual-sig required for transfer)

    Per-pool (hierarchical):

    • adminmanagerrebalancer / rrr_controller

    Key Mechanisms

    Reserve Ratio Requirements (RRR): rrr_floor and rrr_ceil (in Nanos) gate rebalance-out/in. Rebalance-in below rrr_floor is permissionless — anyone can liquidate holdings to restore solvency.

    Flash Rebalancing: StartRebal / EndRebal must appear in the same tx. StartRebal appends a RebalAux segment to the Pool account; EndRebal validates 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, ClaimHolding lets 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)

    CategoryInstructions
    UserMint, TransferThenMint, TransferWrappedThenMint, Claim, ClaimWrapped, BurnThenClaim, BurnThenClaimWrapped, Swap, ClaimHolding
    Pool LifecycleInit, Close
    Pool AdminSetAdmin, SetManager, SetHolding, RemHolding, SetSolOutLossTol, SetRebalancer
    RebalancingStartRebal, EndRebal
    RRRSetRRRController, SetRRR
    ProtocolSetProtocolFeeController, SetProtocolFees, SetSvcWhitelistAuth, AddSvc, RemSvc, SetProtocolFeeBeneficiary, ClaimProtocolFees
    MaintenanceSyncRentPool, GarbageCollect

    Mint and Claim are permissionless by design — security relies entirely on the depositor_due surplus 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 the core crate

    Notable Design Observations

    Strengths: Clean core/program separation enables isolated verification. Flash rebalance pattern is well-scoped. Permissionless liquidation at rrr_floor prevents insolvency. Kani formal verification is meaningful.

    Complexity / Risk Areas:

    • RebalAux in-place account extension is non-standard; cleanup guarantees require careful validation
    • depositor_due spans four lamport fields — multiple sites for invariant violations
    • ClaimHolding / Swap variant 2 fee conversion uses potentially stale sol_value — precision-sensitive
    • Mint / Claim permissionless design puts full trust in depositor_due computation — high-value correctness target
  7. Dead perm Computation on Same-Mint Flash Loan Path

    State

    Fixed

    PR #52

    Severity

    Severity: Informational

    Submitted by

    Jay


    Description

    In process_start_rebal, the perm variable (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, perm is set to RebalSolInpRRArgs::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 by quote_rebal_sol_inp_exact_out in the SOL-input rebalance branch (Sol → Other). When is_same_mint_flash_loan is true, the code takes an early path that computes rebal_aux_min directly and skips all quoting logic entirely meaning perm is 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.

  8. Napstec Improvements: Undocumented Permissionless SOL Flash Loans via Same Mint Rebalance Path

    State

    Fixed

    PR #51

    Severity

    Severity: Informational

    Submitted by

    Jay


    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. At EndRebal, 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 EndRebal refreshes the holding's sol_value via 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 no sol_value to refresh, so the flash loan produces no side effect for the protocol.

    The DESIGN.md documentation 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 are TokenT::Sol, restricting the feature to LST to LST only.

  9. [Appendix] Formal Verification: swap_holding.rs

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Jay


    Spec Overview for core::rebal::swap_holding.rs

    FunctionalityDetailsVerification Status
    function swap_holding_fee_decomposition
    PreconditionsValid ClaimHoldingQuote with sol_value_out > 0
    PreconditionsValid deposit_fee Nanos (<= 1B), tight bounds (values <= 10)
    Postconditionsdeposit_fee_lamports + qtys.out() = sol_value.out (AftFee invariant)
    function swap_holding_out_bounded_by_sol_value
    PreconditionsValid ClaimHoldingQuote, deposit_fee Nanos
    Postconditionsqtys.out() <= sol_value.out (deposit fee is always >= 0)
    function swap_holding_input_preserved
    PreconditionsValid ClaimHoldingQuote with claim_inp > 0
    Postconditionsqtys.inp() = claim_inp (input token amount forwarded from claim)
    function swap_holding_holding_fields_preserved
    PreconditionsValid ClaimHoldingQuote with claim_out, sol_value_out
    Postconditionsholding.amt() = claim's amt.out()
    Postconditionsholding.sol_value() = claim's sol_value.out()
    function swap_other_end_to_end_surplus
    PreconditionsValid inp holding with outstanding, sol_value, balance (tight bounds <= 10)
    PreconditionsInp pool has liq_avail = 0, zero fees for tractability
    PreconditionsOut pool is solvent: mint_supply <= dep_due
    PostconditionsOut pool surplus exactly preserved after outstanding += delta, mint_supply += delta
    PostconditionsInp 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"    );}
  10. [Appendix] Formal Verification: sol_out.rs & sol_inp.rs

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Jay


    Spec Overview for core::rebal::sol_out.rs and core::rebal::sol_inp.rs

    SOL Out (quote_rebal_sol_out_exact_out_sol_val)

    FunctionalityDetailsVerification Status
    function sol_out_outstanding_equals_out
    Preconditionsout > 0, valid pool state with rent_exempt <= pool_acc_lamports
    PreconditionsBounds: out <= 10M, pool_acc_lamports <= 10M
    Postconditionsq.outstanding = out
    Postconditionsq.sol_value.out() = out
    function sol_out_inp_bounded_by_out
    Preconditionsout > 0, pool_rent_exempt <= pool_total
    Preconditionssol_out_loss_tol_raw <= 10M (max ~1% loss tolerance)
    Postconditionssol_value.inp() <= sol_value.out() (loss tolerance means pool accepts less LST value)
    function sol_out_preserves_dep_due
    PreconditionsValid pool with rent_exempt, outstanding, protocol_fee
    Preconditionsout > 0, sol_out_loss_tol valid
    PostconditionsAfter rebalance: pool_acc_lamports -= out, outstanding += out
    Postconditionsdep_due_after = dep_due_before (dep_due is preserved)

    SOL Inp (quote_rebal_sol_inp_exact_out_inner)

    FunctionalityDetailsVerification Status
    function sol_inp_inp_is_returning_plus_fees
    Preconditionsout > 0, out <= holding_balance, valid holding and fee ratios
    PreconditionsBounds: values <= 10M, fee nanos < 1B
    Postconditionsamt.inp() = lamports_returning + protocol_fees_total
    function sol_inp_outstanding_bounded
    Preconditionsout > 0, out <= holding_balance
    PreconditionsTight bounds (values <= 10) due to Ceil::apply u128 arithmetic
    Postconditionslamports_returning <= holding.outstanding
    function sol_inp_preserves_dep_due
    PreconditionsValid pool state, valid holding with tight bounds
    Preconditionsrent_exempt <= pool_acc, pool values <= 100
    Postconditionspool_acc_lamports += inp, outstanding -= returning, protocol_fee += pf
    Postconditionsdep_due_after = dep_due_before (dep_due is preserved)
    function sol_inp_no_perf_fee_when_no_gains
    Preconditionssol_value <= outstanding (holding has not appreciated)
    PreconditionsValid holding, fee ratios, and out <= holding_balance
    Postconditionsprotocol_fees.perf() = 0 (no performance fee charged)
    function sol_inp_sol_value_inp_equals_amt_inp
    PreconditionsValid holding, fee ratios, and out <= holding_balance
    Postconditionssol_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"        );    }}