Skip to content

farming-pool: stake() and unstake() perform the token transfer before persisting UserStake changes #71

Description

@prodbycorne

Problem

Both stake and unstake perform the external token transfer before the UserStake record is persisted or removed:

pub fn stake(env: Env, from: Address, amount: i128) -> Result<(), PoolError> {
    // ... compute `new_stake` in memory ...
    new_stake.credit_rate = read_credit_rate(&env);

    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(
        &from, &env.current_contract_address(), &amount,
    );                                    // <-- external call before persisting

    set_user_stake(&env, &from, &new_stake);
    Ok(())
}

pub fn unstake(env: Env, from: Address) -> Result<i128, PoolError> {
    let mut stake = get_user_stake(&env, &from).expect("no active stake");
    checkpoint(&env, &from, &mut stake);
    let total_credits = stake.credits_banked;

    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(
        &env.current_contract_address(), &from, &stake.amount,
    );                                    // <-- external call before removing state

    remove_user_stake(&env, &from);
    Ok(total_credits)
}

Just as with lock_assets/unlock_assets (see companion issues), stake_token is an admin-supplied address, not guaranteed to be a passive Stellar Asset Contract. A transfer call to a non-standard token can re-enter FarmingPool before set_user_stake/remove_user_stake runs. In unstake specifically, that means a reentrant call sees the full, not-yet-removed UserStake.amount and could authorize a second unstake/emergency_withdraw payout for the same underlying tokens before the first call's remove_user_stake commits.

Impact

  • Fund loss risk in unstake: reentrant double-payout of stake.amount before the record is cleared, if stake_token is a non-standard/malicious contract.
  • State-consistency risk in stake: a reentrant read of get_user_stake/get_credits mid-call would observe the stale pre-deposit UserStake (or none, on first stake), inconsistent with the deposit that has already left the caller's wallet.

Fix

pub fn stake(env: Env, from: Address, amount: i128) -> Result<(), PoolError> {
    // ... compute `new_stake` ...
    new_stake.credit_rate = read_credit_rate(&env);
    set_user_stake(&env, &from, &new_stake);   // effects first

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

pub fn unstake(env: Env, from: Address) -> Result<i128, PoolError> {
    let mut stake = get_user_stake(&env, &from).ok_or(PoolError::NoActiveStake)?;
    checkpoint(&env, &from, &mut stake);
    let total_credits = stake.credits_banked;
    let amount = stake.amount;
    remove_user_stake(&env, &from);            // effects first

    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(
        &env.current_contract_address(), &from, &amount,
    );
    Ok(total_credits)
}

Acceptance Criteria

  • set_user_stake in stake is called before the external transfer.
  • remove_user_stake in unstake is called before the external transfer.
  • Add a reentrancy test using a mock malicious token contract that re-enters unstake/get_stake from within transfer, asserting the reentrant call cannot obtain a second payout and observes the already-cleared UserStake.
  • Existing test_unstake_returns_tokens_and_credits, test_additional_stake_checkpoints_credits continue to pass.

Additional Notes

Confirmed against current source: stake (farming-pool/src/lib.rs:404-436) builds new_stake in memory (lines 412-423), transfers at lines 427-432, and only calls set_user_stake(&env, &from, &new_stake) at line 434. unstake (438-457) calls get_user_stake(&env, &from).expect("no active stake") at line 444, checkpoint at 445, transfers at lines 448-453, and only calls remove_user_stake(&env, &from) at line 455 — confirming both orderings exactly as described.

Edge cases:

Implementation sketch: in stake, move set_user_stake(&env, &from, &new_stake); (currently line 434) to immediately after new_stake.credit_rate = read_credit_rate(&env); (line 425) and before the get_stake_token/transfer block (427-432). In unstake, replace .expect("no active stake") (line 444) with .ok_or(PoolError::NoActiveStake)? (coordinating with #65), capture stake.amount into a local amount binding before calling remove_user_stake(&env, &from) (currently line 455) earlier — immediately after checkpoint/total_credits capture (line 446) — and before the transfer block (448-453), exactly as the issue's fix snippet shows.

Test plan: using the shared mock-reentrant-token helper (see #69), add test_stake_reentrant_transfer_observes_post_deposit_state and test_unstake_reentrant_transfer_cannot_double_payout, the latter specifically asserting a reentrant unstake/get_stake call inside the malicious token's transfer sees None/an already-cleared UserStake and cannot obtain a second payout. Confirm test_unstake_returns_tokens_and_credits and test_additional_stake_checkpoints_credits (confirmed present) still pass unmodified.

Cross-references: #65 (same line 444 in unstake — coordinate), #60 (struct-field addition affects the persist-timing fix here), #69/#70/#72 (identical CEI pattern, shared test infra).

Metadata

Metadata

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