Freeze unstaking coins during cooldown; close account on full unstake#1
Merged
Conversation
RequestUnstake now settles and removes the requested coins from the pool's reward accounting immediately, so coins awaiting unstake stop earning new rewards during the cooldown. Their already-earned rewards are paid out at request time; only the token transfer is deferred to CompleteUnstake. The proportional immature-reward forfeiture and weight removal (linear in amount) already applied on direct Unstake now also apply at request time, so a partial request loses only the proportional share of immature rewards and weight. A full unstake (direct or via CompleteUnstake) now fully resets the position and closes the stake account to reclaim rent, except when the pool still owes residual SOL (kept open so the user can claim it, then close). CancelUnstakeRequest restores the frozen coins to the active position: stake and weight are added back at the original maturity, and the re-added tokens get a fresh reward snapshot so they earn no cooldown-period rewards. - unstake.rs: extract settle_unstake_accounting + close_user_stake_account helpers; execute_unstake closes on full unstake; optional metadata account - request_unstake.rs: settle + auto-pay + freeze; owner now writable - complete_unstake.rs: deliver frozen tokens + close on full unstake - cancel_unstake.rs: restore frozen stake; pool now writable; residual guard - error.rs: add ResidualRewardsPending - lib.rs/idl.json/README.md: updated account tables and docs - test_staking.ts: writable flags, optional metadata, new cooldown tests Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review: on a full unstake we pay out the user's rewards in full (the pool holds every staker's unclaimed rewards, so a shortfall should not occur) and close the account 100% of the time. Any theoretical unpayable remainder is redistributed to remaining stakers via last_synced_lamports rather than stranded on a closed account — so a fully-unstaked position never carries residual. - settle_unstake_accounting: full-unstake branch zeroes reward_debt and redistributes any remainder instead of storing residual / total_residual_unpaid - execute_unstake / complete_unstake: close unconditionally when amount == 0 Fix E2E tests for the auto-close behavior (close refunds the user's own stake-account rent, which is not reward SOL): - add rewardExcludingRent() helper that subtracts refunded rent on close - conservation + multi-phase reconciliation tests use it for full unstakes - "Cannot claim after full unstake": assert the account is closed and a follow-up claim fails, instead of expecting a zero-value claim Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A request created by the pre-upgrade code never removed the coins from the pool (old RequestUnstake only set the request fields). The new Cancel/Complete would otherwise add those coins back (Cancel) or under-remove them (Complete), crediting the user stake they never lost. Add a 1-byte UserStake.unstake_request_settled marker, set to 1 only by the new RequestUnstake. Legacy accounts deserialize it as 0 (relying on realloc zero-fill, same as claimed_rewards_wad). Branch on it: - Cancel: settled==1 restores coins to the pool; settled==0 (legacy) just clears the request — nothing to restore. - Complete: settled==1 delivers tokens + closes; settled==0 (legacy) runs the full execute_unstake (settle + remove from pool + transfer + close), matching the pre-upgrade flow. UserStake::LEN 177 -> 178; LEGACY_LEN stays 161. Updated new(), BorshDeserialize, size/roundtrip tests (incl. legacy classifies as settled==0), and idl.json. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProgramTest/BanksClient tests that craft the exact pre-upgrade on-chain state (177-byte UserStake with a pending request, coins still counted in the pool) and verify the upgraded program handles it: - legacy_cancel_does_not_credit_stake: CancelUnstakeRequest on a legacy request must restore nothing — total_staked and the user's amount stay unchanged. - legacy_complete_full_removes_and_closes: CompleteUnstake routes legacy requests through the full execute_unstake (real Token-2022 transfer CPI), draining the vault, zeroing total_staked, and closing the account. These are deterministic (no validator/old binary needed). Wire `cargo test` into CI, which previously only ran build-sbf + the TS E2E suite (so the 33 unit tests now run in CI too). Adds spl-token-2022 as a dev-dependency for the token CPI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
solana-program-test -> solana-svm -> prost-build requires the Protocol Buffers compiler at build time. build-sbf doesn't need it, but the new cargo test step does. Install protobuf-compiler on the e2e-tests runner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoid stale runs piling up and competing for runners when pushing repeatedly to an open PR (push + pull_request each trigger a run). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reworks the unstake flow so that:
It also makes the upgrade safe for unstake requests that are already in flight when the program is upgraded.
Behavior
Cooldown: requested coins stop earning (#4)
RequestUnstakenow settles the request immediately instead of leaving the coins staked:total_staked/sum_stake_exp) at request time, so they earn nothing during the cooldown.Unstake);owneris nowwritableto receive the SOL.CompleteUnstake; the tokens stay in the vault until the cooldown elapses.Partial unstake/request (#1, #3)
Weight is linear in
amountfor a fixedexp_start_factor, so removingamount × exp_start_factordrops weight by exactly the unstaked ratio, and the forfeited-immature-reward redistribution is scaled to the unstaked fraction. This already held for directUnstake; it now also applies at request time.Full unstake closes the account (#2)
A full unstake (direct
UnstakeorCompleteUnstake) pays out the user's rewards in full and closes the account 100% of the time. The pool holds every staker's unclaimed rewards, so a shortfall should never occur; if one ever did, the unpayable remainder is redistributed to the remaining stakers vialast_synced_lamportsrather than being stranded on a closed account. An optional trailing metadata account lets the close decrementmember_count.Cancel restores the position
CancelUnstakeRequestadds the frozen coins back to the active position (total_staked/sum_stake_exprestored at the original maturity) and gives the re-added tokens a fresh reward snapshot so they earn no cooldown-period rewards (mirrors add-stake).poolis nowwritable.Migration safety for legacy in-flight requests
Requests created by the old code only set the request fields — they never removed the coins from the pool. The new
Cancel/Completewould otherwise double-credit (Cancel) or under-remove (Complete) them. Since legacy-pending and new-pending look identical in the per-account fields, a 1-byteUserStake.unstake_request_settledmarker is added (set to1only by the newRequestUnstake; legacy accounts deserialize it as0, relying on realloc zero-fill likeclaimed_rewards_wad). Branching on it:settled==1restores to the pool;settled==0(legacy) just clears the request, restoring nothing.settled==1delivers tokens + closes;settled==0(legacy) runs the fullexecute_unstake(settle + remove from pool + transfer + close), matching the pre-upgrade flow.UserStake::LENgrows 177 → 178 (LEGACY_LENstays 161); old 153/161/177-byte accounts are still read and lazily reallocated.Files
instructions/unstake.rs— extractsettle_unstake_accounting+close_user_stake_accounthelpers;execute_unstakealways closes on full unstake; optional metadata account.instructions/request_unstake.rs— settle + auto-pay + freeze; setunstake_request_settled = 1;ownerwritable.instructions/complete_unstake.rs— new-style: deliver + close; legacy: fullexecute_unstake.instructions/cancel_unstake.rs— restore frozen stake (new) / clear-only (legacy);poolwritable.state.rs—unstake_request_settledfield; LEN 177→178; deserialize + tests.error.rs—ResidualRewardsPending(defensive guard).lib.rs/idl.json/README.md— account tables,UserStakelayout, docs.Testing
settled==0.tests/legacy_migration.rs, ProgramTest/BanksClient) — craft the exact pre-upgrade on-chain state and verify:legacy_cancel_does_not_credit_stake: legacy cancel leavestotal_stakedand the user's amount unchanged (no stake credited).legacy_complete_full_removes_and_closes: legacy complete runs the full unstake (real Token-2022 transfer CPI), drains the vault, zeroestotal_staked, and closes the account.test_staking.ts, both SPL Token and Token-2022) — new cooldown tests (frozen coins stop earning, partial-request proportional removal, full-unstake close, cancel restore); conservation/multi-phase tests adjusted to exclude refunded close rent; "cannot claim after full unstake" rewritten for auto-close.cargo testis now run in CI (it previously only ranbuild-sbf+ the TS suite), withprotobuf-compilerinstalled forsolana-program-test; added aconcurrencygroup withcancel-in-progressto avoid stale runs piling up.🤖 Generated with Claude Code