Skip to content

feat(farming-pool): support partial withdrawal in unstake (closes #77) - #140

Open
Cyber-Mitch wants to merge 1 commit into
SmartDropLabs:mainfrom
Cyber-Mitch:fix/77-unstake-partial-withdrawal
Open

feat(farming-pool): support partial withdrawal in unstake (closes #77)#140
Cyber-Mitch wants to merge 1 commit into
SmartDropLabs:mainfrom
Cyber-Mitch:fix/77-unstake-partial-withdrawal

Conversation

@Cyber-Mitch

Copy link
Copy Markdown
Contributor

Closes #77

unstake() always withdrew the entire staked balance — no way to withdraw part of a position while keeping the rest earning credits, unlike unlock_assets(), which already supports partial withdrawal for the lock system. This adds an amount parameter to unstake, mirroring unlock_assets's pattern.

⚠️ Breaking ABI change — read before merging

Before: unstake(from: Address) -> Result<i128, PoolError> — always withdrew the entire stake.
After: unstake(from: Address, amount: i128) -> Result<i128, PoolError> — withdraws exactly amount.

amount is a new required third parameter. Existing two-argument callers fail at the VM boundary with Func(MismatchingParameterLen) / Error(WasmVm, UnexpectedSize) — a hard invocation failure, not a silent misread.

Migration — to preserve previous behavior, read the stake first and pass its full amount:

let stake = pool.get_stake(&user).expect("no active stake");
pool.unstake(&user, &stake.amount);

New error variant: PoolError::InvalidAmount = 10, returned when amount <= 0 or amount > stake.amount. Existing discriminants 1–9 are unchanged — no other error code shifts.

Behavior change on the no-stake path: calling unstake with no stake record previously panicked (expect("no active stake")); it now returns the typed PoolError::NoActiveStake = 8, matching emergency_withdraw. Callers relying on a trap must handle a typed error instead.

Unchanged: the return value is still the caller's full banked credit balance after checkpointing — it is not prorated to amount. Boost allocations (DataKey::UserBoost) survive partial withdrawals and continue applying to the remainder.

Downstream fixture note: factory/tests/fixtures/farming_pool.wasm is a checked-in build of this contract and has been regenerated. Any other repo vendoring farming-pool WASM must re-vendor it.

Recon found three drifts from the issue's snapshot

Issue claimed Actual current code
PoolError has a gap between discriminants 3 and 13 False — contiguous 1..=9, no gap
MinLockPeriodOutOfRange is a farming-pool variant from #89 Wrong contract — it's FactoryError (factory/src/types.rs:79)
3 call sites need updating 6 sites across 2 files — the issue missed both reentrancy tests and the entire factory integration crate

#89 has landed on this base (InvalidCreditRate=3, InvalidGlobalMultiplier=4, NotInitialized=2). #75's property tests have not landed — that work lives on an unmerged branch; extending its generator with partial-unstake ops (as the issue suggests as a follow-up) isn't possible on this base and wasn't attempted.

Also found: unstake() used .expect("no active stake") — an untyped panic — despite PoolError::NoActiveStake = 8 already existing and already used by emergency_withdraw. Fixed as part of this change (see the breaking-change notice above).

Design decisions

  • InvalidAmount = 10 — the enum is contiguous 1..=9, so 10 is the next free value. Nothing in the repo uses a discriminant ≥ 10.
  • No full_unstake wrapper. unlock_assets — the function this issue says to mirror — has supported partial withdrawal since inception with no full_unlock companion; a wrapper would make unstake less consistent with its own template. It also can't preserve real backward compatibility anyway: deployed clients call by WASM ABI, and a 2-arg call breaks regardless of whether a 3-arg convenience function exists alongside it. Confirmed no script, Makefile, or doc in the repo calls unstake in a way a wrapper would help.
  • Typed errors, not assert!. unlock_assets (the template) validates with assert!; unstake now uses typed PoolError returns instead. Deliberate divergence, confirmed before implementation — harmonizing unlock_assets onto typed errors would be a natural but separate follow-up.

UserBoost orphaning — verified by reading, then by mutation

checkpoint() reads get_user_boost fresh on every call (lib.rs:265-266) and UserStake caches no boost field, so a partial unstake leaving a remainder cannot leave stale boost data. Not left at inspection: I mutation-tested it — injecting a UserBoost removal into the partial-withdrawal branch made test_unstake_partial_preserves_boost_allocation fail on the credited amount exactly as predicted (21000 vs expected 24000). A second mutation (checkpoint moved after the balance subtraction) was independently caught by 4 tests.

Tests (118 passing, +7 new; 191 total across the workspace)

  • test_unstake_partial_keeps_remaining_stake — mirrors test_unlock_assets_partial_keeps_remaining_position; asserts continued accrual after partial withdrawal.
  • test_unstake_partial_preserves_boost_allocation — mutation-verified per above.
  • Boundary: amount == stake.amount (identical to full unstake, get_stake returns None afterward), amount == stake.amount + 1 (rejected), amount == 0 (rejected), negative amount (rejected).
  • NoActiveStake typed-error test for the no-stake path.
  • All 6 existing call sites updated for the new signature, including a reentrancy test at test.rs:1855 whose arity would otherwise have silently mismatched — its reentry_was_rejected() check would have returned true for the wrong reason (an arity failure, not a rejected re-entrant call), testing nothing.

Verification

Check Result
cargo build --workspace --target wasm32v1-none --release
cargo test --workspace ✅ 191 passed (factory 48, factory_pool_integration 5, farming-pool 118 [+7], vesting-wallet 20)
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings ✅ (matches CI's exact command — ci.yml:41 doesn't use --all-features)

Changed files

factory/tests/factory_pool_integration.rs | 4 +-
factory/tests/fixtures/farming_pool.wasm | Bin 41540 -> 43198
farming-pool/src/lib.rs | 64 ++++++-
farming-pool/src/test.rs | 150 +++++++++++++-
farming-pool/src/types.rs | 3 +

Two files beyond lib.rs/types.rs/test.rs were required, not scope creep: the integration crate has its own call sites (missed by the issue), and the checked-in WASM fixture had to be regenerated since the integration test deploys from it via include_bytes! rather than a fresh build — leaving it stale fails with MismatchingParameterLen.

Snapshot files intentionally excluded. Running the suite regenerates test_snapshots/*.json — confirmed via a pristine git archive export that 37 tracked snapshots already drift at HEAD with zero source changes, a pre-existing repo hygiene issue this PR doesn't touch. Excluded from this commit to keep the diff to the funds-custody logic actually being changed.

Acceptance criteria

  • unstake accepts an amount parameter and supports partial withdrawal, remainder still earning credits
  • unstake rejects amount <= 0 or amount > stake.amount with a typed error
  • test_unstake_partial_keeps_remaining_stake present, mirroring test_unlock_assets_partial_keeps_remaining_position
  • test_unstake_returns_tokens_and_credits, test_pause_blocks_unstake, test_unpause_restores_unstake updated for the new signature (plus 3 more sites the issue didn't name)

Found, not fixed

  1. Documented-but-nonexistent unstaked/staked events. docs/events.md specifies an unstaked event with (user, amount, total_credits) and a staked event, but neither unstake() nor stake() emits anything — only locked/unlocked/emrg_exit/adm_xfr/upgraded exist. Pre-existing drift, not introduced here. Worth its own issue — the payload docs/events.md specifies is now finally expressible with amount available.
  2. Pre-existing snapshot hygiene issue (detailed above) — worth its own cleanup ticket.
  3. unlock_assets still validates with assert! while unstake now uses typed errors (deliberate, per the design decision above) — flagging the inconsistency for awareness, not proposing to fix it here since harmonizing would be its own breaking change.

@netlify

netlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy Preview for sdcontracts ready!

Name Link
🔨 Latest commit f885979
🔍 Latest deploy log https://app.netlify.com/projects/sdcontracts/deploys/6a8f508ebb00070009ac2b27
😎 Deploy Preview https://deploy-preview-140--sdcontracts.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant