Skip to content

farming-pool: unlock_assets transfers tokens to the user before removing/updating the Position record #70

Description

@prodbycorne

Problem

unlock_assets transfers tokens out to the user before the Position record reflecting the reduced (or removed) balance is written:

pub fn unlock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> {
    // ...
    checkpoint_position(&env, &mut position);
    let total_credits = position.total_credits;
    position.amount -= amount;                 // computed in-memory only

    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(
        &env.current_contract_address(),
        &user,
        &amount,
    );                                          // <-- external call happens here, with old
                                                  //     position.amount still in storage

    if position.amount == 0 {
        remove_position(&env, &user);
    } else {
        set_position(&env, &user, &position);    // <-- write happens after the call returns
    }
    // ...
}

Because stake_token is an admin-chosen address (not guaranteed to be a well-behaved SAC — see the lock_assets reentrancy issue for the same root cause), its transfer implementation can call back into FarmingPool before this call's own set_position/remove_position executes. At that point, persistent storage still shows the user's full pre-withdrawal position.amount, even though the tokens for amount have already left the contract. A reentrant unlock_assets/emergency_withdraw call in that window would compute against the stale, still-full balance and could authorize transferring out the same tokens a second time before the first call's set_position/remove_position ever commits.

Impact

  • Fund loss risk: a reentrant call during the transfer-out window sees a Position that has not yet been decremented, so an amount could be withdrawn twice from the contract's token balance for a single locked position, if stake_token is (or becomes, via a future admin-driven token migration) a non-standard contract.
  • Same root cause across the withdrawal surface: unstake and emergency_withdraw have the identical ordering (see companion issues), so a fix here should be applied uniformly.

Fix

Persist the updated/removed position before transferring funds out:

pub fn unlock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> {
    // ...
    checkpoint_position(&env, &mut position);
    let total_credits = position.total_credits;
    position.amount -= amount;

    if position.amount == 0 {
        remove_position(&env, &user);
    } else {
        set_position(&env, &user, &position);
    }

    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(
        &env.current_contract_address(), &user, &amount,
    );

    env.events().publish(/* ... */);
    Ok(())
}

Acceptance Criteria

  • set_position/remove_position is called before token::TokenClient::transfer in unlock_assets.
  • Add a test with a mock reentrant token contract whose transfer implementation calls back into unlock_assets/get_user_position for the same user, and assert the reentrant call observes the already-decremented (or removed) position, and cannot double-withdraw.
  • Existing test_unlock_assets_full_returns_tokens_and_credits, test_unlock_assets_partial_keeps_remaining_position continue to pass.
  • Document the checks-effects-interactions ordering requirement in a code comment.

Additional Notes

Confirmed against current source: unlock_assets (farming-pool/src/lib.rs:265-303) computes position.amount -= amount in memory at line 283, calls token::TokenClient::new(&env, &stake_token).transfer(&env.current_contract_address(), &user, &amount) at lines 286-290, and only then does if position.amount == 0 { remove_position(...) } else { set_position(...) } at lines 292-296 — confirming the transfer-before-persist ordering exactly.

Edge cases:

Implementation sketch: move the if position.amount == 0 { remove_position(&env, &user); } else { set_position(&env, &user, &position); } block (currently lines 292-296) to before the get_stake_token/transfer block (currently lines 285-290), exactly as the issue's fix snippet shows, while preserving checkpoint_position/total_credits capture ordering (lines 281-282) which happens earlier and is unaffected.

Test plan: using the shared mock-reentrant-token helper (see #69), add test_unlock_assets_reentrant_transfer_cannot_double_withdraw where the malicious token's transfer callback attempts a second unlock_assets/emergency_withdraw for the same user mid-call, asserting the reentrant call observes the already-decremented/removed position and fails with NoActiveStake or an amount-exceeds-balance error rather than succeeding. Confirm test_unlock_assets_full_returns_tokens_and_credits and test_unlock_assets_partial_keeps_remaining_position (confirmed present) still pass unmodified.

Cross-references: #65 (.expect on the same function), #66 (amount/lock-period asserts on the same function), #68 (pause guard on the same function) — all four should land as one coordinated change to unlock_assets. #69/#71/#72 (identical CEI pattern elsewhere, shared test infra).

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26farming-poolFarmingPool contractsecuritySecurity vulnerability or hardeningvery hardExtremely hard — deep expertise, careful design, and significant time required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions