Skip to content

LP Vault dual-domain redemption deadlock permanently freezes user capital on resolved markets #419

Description

@v1ktorrr0x

LP Vault dual-domain redemption deadlock permanently freezes user capital on resolved markets

Impact

Direct loss of funds or permanent freezing of funds.

On market resolution, any LP whose position spans both backing domains of an asset cannot redeem their capital. Both redemption routes reject with counter underflow, rebalancing is permanently locked, and market teardown fails closed.

Description

The redemption handler enforces an asymmetric execution model where shares are priced across combined domain capacity but redeemed against a single domain:

let available_principal = lp_vault_combined_available_principal_atoms(&group, ..., registry.domain, &ledger_data, &sibling_ledger_data)?;
let nav = lp_vault_combined_nav_atoms(&group, ..., registry.domain, registry.fee_share_bps, &ledger_data, &sibling_ledger_data)?;
let atoms_out = percolator::lp_vault::lp_atoms_for_redemption(redemption.shares, registry.total_lp_shares_outstanding, nav)?;

When a user redeems, atoms_out reflects both Domain 0 and Domain 1 principal. However, execution requires selecting one source_domain:

if principal_portion > ledger.total_principal_atoms {
    return Err(PercolatorError::EngineCounterUnderflow.into());
}

If the redemption size exceeds either individual domain pot, execution fails on both domains.

Rebalancing between domains is restricted to Live markets:

let (cfg, mode, configured_slots, _) = state::read_market_config_mode_and_capacity(&market_ai.try_borrow_data()?)?;
if mode != MarketModeV16::Live {
    return Err(PercolatorError::EngineLockActive.into());
}

Once a market resolves, mode == Resolved. Rebalancing cannot consolidate the pots, redemption requests cannot be fulfilled, and CloseLpVault fails because shares remain outstanding.

Not the same as LP-VAULT-REDEEM-BUG

  • That issue concerned valid_liened_backing_num zero-OI calculations.
  • This failure is an arithmetic partition deadlock across dual-domain backing ledgers.
  • The lock triggers on completely flat, resolved markets with zero active trader open interest.

On cancelling redemption requests

  • Cancelling via CancelRedemption only returns the unburned shares to the user.
  • Re-requesting redemption re-encounters the exact same single-domain liquidity exhaustion.

Proof of Concept

Target files:

  • src/v16_program.rs: handle_execute_redemption and handle_rebalance_lp_vault_backing

Run command:
cargo test --test audit_poc_suite test_poc_vuln_01_lp_vault_dual_domain_redemption_deadlock -- --nocapture

#[test]
fn test_poc_vuln_01_lp_vault_dual_domain_redemption_deadlock() {
    install_clock_stub();

    let admin = TestAccount::new(Pubkey::new_unique(), Pubkey::new_unique(), 0).signer();
    let mut market = TestAccount::new(
        Pubkey::new_unique(),
        program_id(),
        state::market_account_len_for_capacity(1).unwrap(),
    ).writable();
    let mut mint = TestAccount::new_with_data(Pubkey::new_unique(), spl_token::ID, make_mint_data(1_000_000));
    let mint_key = mint.key;

    // 1. Initialize market
    run_ix(
        Instruction::InitMarket {
            max_portfolio_assets: 1,
            h_min: 0,
            h_max: 10,
            initial_price: 100,
            min_nonzero_mm_req: 1,
            min_nonzero_im_req: 2,
            maintenance_margin_bps: 10_000,
            initial_margin_bps: 10_000,
            max_trading_fee_bps: 10_000,
            trade_fee_base_bps: 0,
            liquidation_fee_bps: 0,
            liquidation_fee_cap: 0,
            min_liquidation_abs: 0,
            max_price_move_bps_per_slot: 10_000,
            max_accrual_dt_slots: 1,
            max_abs_funding_e9_per_slot: 0,
            min_funding_lifetime_slots: 1,
            max_account_b_settlement_chunks: 1,
            max_bankrupt_close_chunks: 1,
            max_bankrupt_close_lifetime_slots: 100,
            public_b_chunk_atoms: percolator::MAX_VAULT_TVL,
            maintenance_fee_per_slot: 0,
        },
        &mut [&mut admin.clone(), &mut market, &mut mint],
    ).unwrap();

    let (registry_pda, registry_bump) = state::derive_lp_vault_registry(&program_id(), &market.key);
    let (lp_mint_pda, _) = state::derive_lp_vault_mint(&program_id(), &market.key);
    let (escrow_pda, _) = state::derive_lp_escrow(&program_id(), &market.key);
    let (v_auth, _) = vault_authority(&market);
    let vault_ata = canonical_vault_ata(&v_auth, &mint_key);

    let redeemer = TestAccount::new(Pubkey::new_unique(), Pubkey::new_unique(), 0).signer();
    let (redemption_pda, redemption_bump) = state::derive_lp_redemption(&program_id(), &registry_pda, &redeemer.key);

    let (domain0_ledger_pda, _) = state::derive_lp_backing_ledger(&program_id(), &market.key, 0);
    let (domain1_ledger_pda, _) = state::derive_lp_backing_ledger(&program_id(), &market.key, 1);

    let mut ledger0 = TestAccount::new(domain0_ledger_pda, program_id(), state::backing_domain_ledger_account_len()).writable();
    let mut ledger1 = TestAccount::new(domain1_ledger_pda, program_id(), state::backing_domain_ledger_account_len()).writable();

    // Set Market to Resolved Mode (mode == 1, c_tot = 0, materialized_portfolio_count = 0)
    {
        let mut market_data = market.data.clone();
        let (mut cfg, group) = state::market_view_mut(&mut market_data).unwrap();
        cfg.collateral_mint = mint_key.to_bytes();
        group.header.mode = 1; // Resolved
        group.header.vault = percolator::V16PodU128::new(100_000);
        group.header.c_tot = percolator::V16PodU128::new(0);
        group.header.materialized_portfolio_count = percolator::V16PodU64::new(0);
        group.header.source_fresh_backing_total_num = percolator::V16PodU128::new(100_000 * BOUND_SCALE);

        let asset_market_id = group.markets[0].engine.asset.market_id.get();

        let mut b0 = group.markets[0].engine.backing_long.try_to_runtime().unwrap();
        b0.market_id = asset_market_id;
        b0.expiry_slot = constants::LP_VAULT_BACKING_EXPIRY_SLOT;
        b0.status = BackingBucketStatusV16::Fresh;
        b0.fresh_unliened_backing_num = 60_000 * BOUND_SCALE;
        group.markets[0].engine.backing_long = percolator::BackingBucketV16Account::from_runtime(&b0);

        let mut s0 = group.markets[0].engine.source_credit_long.try_to_runtime().unwrap();
        s0.fresh_reserved_backing_num = 60_000 * BOUND_SCALE;
        s0.credit_rate_num = CREDIT_RATE_SCALE;
        group.markets[0].engine.source_credit_long = percolator::SourceCreditStateV16Account::from_runtime(&s0);

        let mut b1 = group.markets[0].engine.backing_short.try_to_runtime().unwrap();
        b1.market_id = asset_market_id;
        b1.expiry_slot = constants::LP_VAULT_BACKING_EXPIRY_SLOT;
        b1.status = BackingBucketStatusV16::Fresh;
        b1.fresh_unliened_backing_num = 40_000 * BOUND_SCALE;
        group.markets[0].engine.backing_short = percolator::BackingBucketV16Account::from_runtime(&b1);

        let mut s1 = group.markets[0].engine.source_credit_short.try_to_runtime().unwrap();
        s1.fresh_reserved_backing_num = 40_000 * BOUND_SCALE;
        s1.credit_rate_num = CREDIT_RATE_SCALE;
        group.markets[0].engine.source_credit_short = percolator::SourceCreditStateV16Account::from_runtime(&s1);

        state::write_wrapper_config(&mut market_data, &cfg).unwrap();
        market.data = market_data;

        let mut prof = state::read_asset_oracle_profile(&market.data, 0).unwrap();
        prof.backing_bucket_authority = registry_pda.to_bytes();
        state::write_asset_oracle_profile(&mut market.data, 0, &prof).unwrap();
    }

    // Initialize Backing Ledgers (Domain 0: 60k, Domain 1: 40k)
    let ledger0_data = state::BackingDomainLedgerAccountV16 {
        market_group: market.key.to_bytes(),
        authority: registry_pda.to_bytes(),
        total_principal_atoms: 60_000,
        total_deposited_atoms: 60_000,
        total_principal_withdrawn_atoms: 0,
        total_earnings_atoms: 0,
        total_earnings_withdrawn_atoms: 0,
        last_observed_bucket_earnings_atoms: 0,
        cumulative_loss_atoms: 0,
        cumulative_recovery_atoms: 0,
        last_observed_unavailable_principal_atoms: 0,
        domain: 0,
        _padding: [0u8; 14],
    };
    state::init_backing_domain_ledger(&mut ledger0.data, &ledger0_data).unwrap();

    let ledger1_data = state::BackingDomainLedgerAccountV16 {
        market_group: market.key.to_bytes(),
        authority: registry_pda.to_bytes(),
        total_principal_atoms: 40_000,
        total_deposited_atoms: 40_000,
        total_principal_withdrawn_atoms: 0,
        total_earnings_atoms: 0,
        total_earnings_withdrawn_atoms: 0,
        last_observed_bucket_earnings_atoms: 0,
        cumulative_loss_atoms: 0,
        cumulative_recovery_atoms: 0,
        last_observed_unavailable_principal_atoms: 0,
        domain: 1,
        _padding: [0u8; 14],
    };
    state::init_backing_domain_ledger(&mut ledger1.data, &ledger1_data).unwrap();

    // Initialize LP Registry & Redemption Request
    let mut registry_acc = TestAccount::new(registry_pda, program_id(), state::lp_vault_registry_account_len()).writable();
    let reg_data = state::LpVaultRegistryV16 {
        market_group: market.key.to_bytes(),
        lp_mint: lp_mint_pda.to_bytes(),
        total_lp_shares_outstanding: 100_000,
        insurance_fee_snapshot_atoms: 0,
        fee_distribution_total_atoms: 0,
        epoch: 0,
        redemption_cooldown_slots: 0,
        fee_share_bps: 10_000,
        oi_reservation_threshold_bps: 0,
        domain: 0,
        paused: 0,
        version: constants::LP_VAULT_VERSION,
        bump: registry_bump,
        mint_bump: 255,
        _padding: [0u8; 6],
        _reserved: [0u8; 16],
    };
    state::init_lp_vault_registry(&mut registry_acc.data, &reg_data).unwrap();

    let mut redemption_acc = TestAccount::new(redemption_pda, program_id(), state::lp_redemption_account_len()).writable();
    let red_data = state::LpRedemptionV16 {
        registry: registry_pda.to_bytes(),
        redeemer: redeemer.key.to_bytes(),
        shares: 99_000,
        request_slot: 1,
        version: constants::LP_VAULT_VERSION,
        bump: redemption_bump,
        _padding: [0u8; 6],
    };
    state::init_lp_redemption(&mut redemption_acc.data, &red_data).unwrap();

    let mut lp_mint_acc = TestAccount::new_with_data(lp_mint_pda, spl_token::ID, make_mint_data(100_000)).writable();
    let mut escrow_acc = TestAccount::new_with_data(escrow_pda, spl_token::ID, make_token_data(lp_mint_pda, registry_pda, 99_000)).writable();
    let mut vault_token_acc = TestAccount::new_with_data(vault_ata, spl_token::ID, make_token_data(mint_key, v_auth, 100_000)).writable();
    let mut vault_auth_acc = TestAccount::new(v_auth, Pubkey::new_unique(), 0);
    let mut redeemer_dest_acc = TestAccount::new_with_data(Pubkey::new_unique(), spl_token::ID, make_token_data(mint_key, redeemer.key, 0)).writable();
    let mut token_prog = TestAccount::new(spl_token::ID, Pubkey::new_unique(), 0).executable();
    let mut cranker = TestAccount::new(Pubkey::new_unique(), Pubkey::new_unique(), 0).signer().writable();

    // ── STEP 1: Attempt ExecuteRedemption against Domain 0 ──────────────────────
    let res_domain0 = run_ix(
        Instruction::ExecuteRedemption { domain: 0 },
        &mut [
            &mut cranker,
            &mut market,
            &mut registry_acc,
            &mut redemption_acc,
            &mut lp_mint_acc,
            &mut escrow_acc,
            &mut vault_token_acc,
            &mut vault_auth_acc,
            &mut ledger0,
            &mut redeemer_dest_acc,
            &mut token_prog,
            &mut ledger1,
        ],
    );
    assert_eq!(
        res_domain0,
        Err(ProgramError::Custom(PercolatorError::EngineCounterUnderflow as u32)),
        "ExecuteRedemption against domain 0 must fail with EngineCounterUnderflow (Custom(25))"
    );

    // ── STEP 2: Attempt ExecuteRedemption against Domain 1 ──────────────────────
    let res_domain1 = run_ix(
        Instruction::ExecuteRedemption { domain: 1 },
        &mut [
            &mut cranker,
            &mut market,
            &mut registry_acc,
            &mut redemption_acc,
            &mut lp_mint_acc,
            &mut escrow_acc,
            &mut vault_token_acc,
            &mut vault_auth_acc,
            &mut ledger0,
            &mut redeemer_dest_acc,
            &mut token_prog,
            &mut ledger1,
        ],
    );
    assert_eq!(
        res_domain1,
        Err(ProgramError::Custom(PercolatorError::EngineCounterUnderflow as u32)),
        "ExecuteRedemption against domain 1 must fail with EngineCounterUnderflow (Custom(25))"
    );

    // ── STEP 3: Attempt RebalanceLpVaultBacking to consolidate pots ───────────────
    let mut sys_prog = TestAccount::new(solana_program::system_program::ID, Pubkey::new_unique(), 0);
    let res_rebalance = run_ix(
        Instruction::RebalanceLpVaultBacking {
            from_domain: 1,
            to_domain: 0,
            amount: 40_000,
        },
        &mut [
            &mut cranker,
            &mut market,
            &mut registry_acc,
            &mut ledger1,
            &mut ledger0,
            &mut sys_prog,
        ],
    );
    assert_eq!(
        res_rebalance,
        Err(ProgramError::Custom(PercolatorError::EngineLockActive as u32)),
        "RebalanceLpVaultBacking must fail on resolved market (mode != Live) with EngineLockActive (Custom(21))"
    );

    // ── STEP 4: Demonstrate Teardown Blocked ────────────────────────────────────
    let mut admin_signer = admin.clone().writable();
    let res_close_lp = run_ix(
        Instruction::CloseLpVault,
        &mut [&mut admin_signer, &mut market, &mut registry_acc, &mut lp_mint_acc],
    );
    assert_eq!(
        res_close_lp,
        Err(ProgramError::Custom(PercolatorError::LpVaultSharesOutstanding as u32)),
        "CloseLpVault must fail because shares are permanently stuck outstanding"
    );
}

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions