Skip to content

farming-pool: unstake() cannot partially withdraw a stake, unlike unlock_assets()'s partial-unlock support #77

Description

@prodbycorne

Problem

The lock/unlock system supports partial withdrawal — unlock_assets(env, user, amount) takes an explicit amount and leaves the remainder locked:

pub fn unlock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> {
    // ...
    assert!(amount <= position.amount, "insufficient locked balance");
    // ...
    position.amount -= amount;
    // ...
    if position.amount == 0 {
        remove_position(&env, &user);
    } else {
        set_position(&env, &user, &position);
    }
    // ...
}

(confirmed exercised by test_unlock_assets_partial_keeps_remaining_position). The boost/stake system's unstake has no such capability — it takes no amount parameter at all and always withdraws the entire UserStake.amount:

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;
    // ...
    token::TokenClient::new(&env, &stake_token).transfer(
        &env.current_contract_address(), &from, &stake.amount,   // always the FULL amount
    );
    remove_user_stake(&env, &from);
    Ok(total_credits)
}

A user who wants to withdraw only part of their staked balance while keeping the rest earning credits (exactly the flexibility unlock_assets already provides for the lock system) has no way to do so in the boost/stake system: they must fully unstake() — losing their UserBoost allocation association for the withdrawn-and-redeposited remainder — and re-stake() the portion they want to keep, incurring an unnecessary round-trip through the token contract and resetting start_ledger/checkpoint timing in a way unlock_assets doesn't force on lock-system users.

Impact

  • Inconsistent, more restrictive UX for the boost/stake system than the lock/unlock system it otherwise mirrors closely (both use the same checkpoint-then-persist pattern, both track credit_rate snapshots).
  • Forces unnecessary token round-trips for a common operation (partial withdrawal), each incurring the full transfer-in/transfer-out cost and gas, purely because the API doesn't support the natural partial-amount case.

Fix

Add an amount parameter to unstake, mirroring unlock_assets's partial-withdrawal pattern:

pub fn unstake(env: Env, from: Address, amount: i128) -> Result<i128, PoolError> {
    from.require_auth();
    require_not_paused(&env)?;
    require_initialized(&env)?;

    let mut stake = get_user_stake(&env, &from).ok_or(PoolError::NoActiveStake)?;
    if amount <= 0 || amount > stake.amount {
        return Err(PoolError::InvalidAmount);
    }
    checkpoint(&env, &from, &mut stake);
    let total_credits = stake.credits_banked;
    stake.amount -= amount;

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

    if stake.amount == 0 {
        remove_user_stake(&env, &from);
    } else {
        set_user_stake(&env, &from, &stake);
    }
    Ok(total_credits)
}

This is a breaking ABI change (new required parameter); consider keeping a full_unstake convenience wrapper for backward compatibility with existing integrators if that's a concern.

Acceptance Criteria

  • unstake accepts an amount parameter and supports partial withdrawal, leaving the remainder staked and still earning credits.
  • unstake rejects amount <= 0 or amount > stake.amount with a typed error.
  • Add test_unstake_partial_keeps_remaining_stake mirroring test_unlock_assets_partial_keeps_remaining_position.
  • Update existing test_unstake_returns_tokens_and_credits, test_pause_blocks_unstake, test_unpause_restores_unstake call sites for the new signature.
  • Document the ABI/signature change in the PR description for downstream client updates.

Additional Notes

Precise references: unstake is at soroban/contracts/farming-pool/src/lib.rs:438-457; the full-withdrawal transfer is line 449-453 (&stake.amount); remove_user_stake unconditionally removes the DataKey::UserStake(from) entry at line 455 regardless of how much is withdrawn — this is the exact line that needs to become conditional (mirroring unlock_assets's if position.amount == 0 { remove_position } else { set_position } at lib.rs:292-296).

Edge cases beyond the issue body's sketch

  • DataKey::UserBoost(user) orphaning: unlike Position (lock system), a stake's allocation_pct boost is stored in a separate key (DataKey::UserBoost, lib.rs:74-81) that unstake never touches today. If partial unstake leaves stake.amount > 0, the existing UserBoost entry correctly continues to apply to the remainder — that's fine and is actually the desired behavior. But if partial unstake reduces the stake to exactly the remainder and the user later calls set_boost again, checkpoint (line 148-162) is invoked from set_boost too (line 470) — confirm the interaction: checkpoint reads get_user_boost fresh each time (line 149), so no staleness risk there. Worth an explicit test to lock this in since it's a two-key (UserStake + UserBoost) invariant that's easy to break in a future refactor.
  • Interaction with farming-pool: no property-based test enforces that total accrued credits are independent of checkpoint frequency #75's path-independence property test: once unstake takes a partial amount, the property test in farming-pool: no property-based test enforces that total accrued credits are independent of checkpoint frequency #75 should be extended to include partial-unstake-then-restake sequences in its random operation generator — a partial withdrawal followed by an equal re-stake should be checkpoint-neutral (same total credits as never withdrawing), and that's a good additional invariant to assert once both issues land.
  • Zero-amount / full-amount boundary: the sketch's amount <= 0 || amount > stake.amount check should have an explicit test for amount == stake.amount (should behave identically to today's full-unstake path, including remove_user_stake) as well as amount == stake.amount + 1 (should reject) — the existing test_unlock_assets_rejects_more_than_locked (farming-pool/src/test.rs:685) is the template.
  • PoolError variant reuse: the sketch introduces PoolError::InvalidAmount, but check types.rs (farming-pool/src/types.rs:7-13) first — no such variant exists yet (current variants: AlreadyInitialized=1, NotInitialized=2, InvalidCreditRate=3, NotPaused=13, NoActiveStake=14); note the existing gap between 3 and 13 in the discriminant sequence, suggesting other variants were removed/reserved at some point — pick a discriminant that doesn't collide with any other in-flight issue in this batch that also proposes new PoolError variants (e.g. farming-pool: set_global_multiplier and set_credit_rate accept unbounded values with no sanity ceiling, guaranteeing compute_credits overflow at scale #89's InvalidGlobalMultiplier).

Implementation sketch (refining the issue body's)

The issue body's sketch calls .ok_or(PoolError::NoActiveStake)NoActiveStake already exists (value 14) and is reused correctly from emergency_withdraw (lib.rs:379-381), good. Add:

pub fn unstake(env: Env, from: Address, amount: i128) -> Result<i128, PoolError> {
    from.require_auth();
    assert!(!pool_is_paused(&env), "pool is paused");
    require_initialized(&env)?;
    let mut stake = get_user_stake(&env, &from).ok_or(PoolError::NoActiveStake)?;
    if amount <= 0 || amount > stake.amount {
        return Err(PoolError::InvalidAmount); // new variant, pick unused discriminant
    }
    bump_instance(&env);
    checkpoint(&env, &from, &mut stake);
    let total_credits = stake.credits_banked;
    stake.amount -= amount;
    let stake_token = get_stake_token(&env)?;
    token::TokenClient::new(&env, &stake_token).transfer(&env.current_contract_address(), &from, &amount);
    if stake.amount == 0 {
        remove_user_stake(&env, &from);
    } else {
        set_user_stake(&env, &from, &stake);
    }
    Ok(total_credits)
}

Test plan

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26farming-poolFarmingPool contractfeatureNew feature or capabilityvery 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