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
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).
Problem
Both
stakeandunstakeperform the external token transfer before theUserStakerecord is persisted or removed:Just as with
lock_assets/unlock_assets(see companion issues),stake_tokenis an admin-supplied address, not guaranteed to be a passive Stellar Asset Contract. Atransfercall to a non-standard token can re-enterFarmingPoolbeforeset_user_stake/remove_user_stakeruns. Inunstakespecifically, that means a reentrant call sees the full, not-yet-removedUserStake.amountand could authorize a secondunstake/emergency_withdrawpayout for the same underlying tokens before the first call'sremove_user_stakecommits.Impact
unstake: reentrant double-payout ofstake.amountbefore the record is cleared, ifstake_tokenis a non-standard/malicious contract.stake: a reentrant read ofget_user_stake/get_creditsmid-call would observe the stale pre-depositUserStake(or none, on first stake), inconsistent with the deposit that has already left the caller's wallet.Fix
Acceptance Criteria
set_user_stakeinstakeis called before the externaltransfer.remove_user_stakeinunstakeis called before the externaltransfer.unstake/get_stakefrom withintransfer, asserting the reentrant call cannot obtain a second payout and observes the already-clearedUserStake.test_unstake_returns_tokens_and_credits,test_additional_stake_checkpoints_creditscontinue to pass.Additional Notes
Confirmed against current source:
stake(farming-pool/src/lib.rs:404-436) buildsnew_stakein memory (lines 412-423), transfers at lines 427-432, and only callsset_user_stake(&env, &from, &new_stake)at line 434.unstake(438-457) callsget_user_stake(&env, &from).expect("no active stake")at line 444,checkpointat 445, transfers at lines 448-453, and only callsremove_user_stake(&env, &from)at line 455 — confirming both orderings exactly as described.Edge cases:
unstake's.expect("no active stake")at line 444 is the exact line farming-pool: unlock_assets and unstake panic via untyped .expect() instead of returning typed PoolError #65 targets for a typed-error fix (.ok_or(PoolError::NoActiveStake)?). This issue's own fix snippet (in the issue body) already shows that rewrite as part of itsunstakesketch — meaning farming-pool: unlock_assets and unstake panic via untyped .expect() instead of returning typed PoolError #65 and farming-pool: stake() and unstake() perform the token transfer before persisting UserStake changes #71 both modify line 444 of the current file. Implement together; whichever PR lands second must rebase onto the other rather than blindly reapplying a stale line-444 patch.stake's pause/init guards run in the orderassert!(!pool_is_paused)(line 406) thenrequire_initialized(line 407) — the opposite order fromlock_assets/unlock_assets/set_boost. This ordering quirk is called out in farming-pool: pause guard has no typed PoolError variant — every gated function panics via assert! on a paused pool #68's notes; the CEI reordering in this issue (movingset_user_stakeearlier) is independent of that guard-ordering question and shouldn't need to touch lines 406-407 at all — confirm the CEI patch only touches the post-computation persist/transfer block (lines ~425-436), not the guards.stake's newset_user_stake(moved earlier) needsnew_stake.credit_ratealready assigned (currently line 425, right before the transfer) — the CEI fix must move the persist call to after line 425, not between the checkpoint (existing/elsebranch construction, 412-423) and the credit_rate assignment, or it would persist a stake missing its current credit-rate snapshot.UserStakegains amultiplierfield (per farming-pool: get_credits retroactively reprices a user's entire uncheckpointed accrual window when the admin changes the global multiplier #60), both thestake-branch construction (lines 417-422, theelsearm building a freshUserStake) and the reorderedset_user_stakecall need to include/preserve that field — sequence farming-pool: get_credits retroactively reprices a user's entire uncheckpointed accrual window when the admin changes the global multiplier #60 before this issue, or rebase.Implementation sketch: in
stake, moveset_user_stake(&env, &from, &new_stake);(currently line 434) to immediately afternew_stake.credit_rate = read_credit_rate(&env);(line 425) and before theget_stake_token/transferblock (427-432). Inunstake, replace.expect("no active stake")(line 444) with.ok_or(PoolError::NoActiveStake)?(coordinating with #65), capturestake.amountinto a localamountbinding before callingremove_user_stake(&env, &from)(currently line 455) earlier — immediately aftercheckpoint/total_creditscapture (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_stateandtest_unstake_reentrant_transfer_cannot_double_payout, the latter specifically asserting a reentrantunstake/get_stakecall inside the malicious token'stransferseesNone/an already-clearedUserStakeand cannot obtain a second payout. Confirmtest_unstake_returns_tokens_and_creditsandtest_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).