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.
The redemption handler enforces an asymmetric execution model where shares are priced across combined domain capacity but redeemed against a single domain:
If the redemption size exceeds either individual domain pot, execution fails on both domains.
#[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(), ®istry_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, ®_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"
);
}
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:
When a user redeems,
atoms_outreflects both Domain 0 and Domain 1 principal. However, execution requires selecting onesource_domain:If the redemption size exceeds either individual domain pot, execution fails on both domains.
Rebalancing between domains is restricted to
Livemarkets:Once a market resolves,
mode == Resolved. Rebalancing cannot consolidate the pots, redemption requests cannot be fulfilled, andCloseLpVaultfails because shares remain outstanding.Not the same as LP-VAULT-REDEEM-BUG
valid_liened_backing_numzero-OI calculations.On cancelling redemption requests
CancelRedemptiononly returns the unburned shares to the user.Proof of Concept
Target files:
src/v16_program.rs:handle_execute_redemptionandhandle_rebalance_lp_vault_backingRun command:
cargo test --test audit_poc_suite test_poc_vuln_01_lp_vault_dual_domain_redemption_deadlock -- --nocapture