Skip to content

Freeze unstaking coins during cooldown; close account on full unstake#1

Merged
MagicalTux merged 6 commits into
masterfrom
partial-full-unstake-rewards
May 23, 2026
Merged

Freeze unstaking coins during cooldown; close account on full unstake#1
MagicalTux merged 6 commits into
masterfrom
partial-full-unstake-rewards

Conversation

@MagicalTux
Copy link
Copy Markdown
Member

@MagicalTux MagicalTux commented May 23, 2026

Summary

Reworks the unstake flow so that:

  1. Partial unstake loses only a proportional share of immature rewards (not the whole position's).
  2. Full unstake fully resets the position and closes the stake account (reclaiming rent), every time.
  3. Partial unstake loses cumulated weight proportional to the unstaked ratio.
  4. Coins with a pending unstake request stop earning new rewards during the cooldown.

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)

RequestUnstake now settles the request immediately instead of leaving the coins staked:

  • The requested coins are removed from the pool's reward accounting (total_staked / sum_stake_exp) at request time, so they earn nothing during the cooldown.
  • Their already-earned rewards are paid out then (auto-claim, mirroring direct Unstake); owner is now writable to receive the SOL.
  • Only the token transfer is deferred to CompleteUnstake; the tokens stay in the vault until the cooldown elapses.

Partial unstake/request (#1, #3)

Weight is linear in amount for a fixed exp_start_factor, so removing amount × exp_start_factor drops weight by exactly the unstaked ratio, and the forfeited-immature-reward redistribution is scaled to the unstaked fraction. This already held for direct Unstake; it now also applies at request time.

Full unstake closes the account (#2)

A full unstake (direct Unstake or CompleteUnstake) 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 via last_synced_lamports rather than being stranded on a closed account. An optional trailing metadata account lets the close decrement member_count.

Cancel restores the position

CancelUnstakeRequest adds the frozen coins back to the active position (total_staked / sum_stake_exp restored at the original maturity) and gives the re-added tokens a fresh reward snapshot so they earn no cooldown-period rewards (mirrors add-stake). pool is now writable.

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/Complete would otherwise double-credit (Cancel) or under-remove (Complete) them. Since legacy-pending and new-pending look identical in the per-account fields, a 1-byte UserStake.unstake_request_settled marker is added (set to 1 only by the new RequestUnstake; legacy accounts deserialize it as 0, relying on realloc zero-fill like claimed_rewards_wad). Branching on it:

  • Cancelsettled==1 restores to the pool; settled==0 (legacy) just clears the request, restoring nothing.
  • Completesettled==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 grows 177 → 178 (LEGACY_LEN stays 161); old 153/161/177-byte accounts are still read and lazily reallocated.

Files

  • instructions/unstake.rs — extract settle_unstake_accounting + close_user_stake_account helpers; execute_unstake always closes on full unstake; optional metadata account.
  • instructions/request_unstake.rs — settle + auto-pay + freeze; set unstake_request_settled = 1; owner writable.
  • instructions/complete_unstake.rs — new-style: deliver + close; legacy: full execute_unstake.
  • instructions/cancel_unstake.rs — restore frozen stake (new) / clear-only (legacy); pool writable.
  • state.rsunstake_request_settled field; LEN 177→178; deserialize + tests.
  • error.rsResidualRewardsPending (defensive guard).
  • lib.rs / idl.json / README.md — account tables, UserStake layout, docs.

Testing

  • Rust unit tests (33) — including legacy-account deserialization classifying as settled==0.
  • Rust integration tests (tests/legacy_migration.rs, ProgramTest/BanksClient) — craft the exact pre-upgrade on-chain state and verify:
    • legacy_cancel_does_not_credit_stake: legacy cancel leaves total_staked and 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, zeroes total_staked, and closes the account.
  • TS E2E (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.
  • CIcargo test is now run in CI (it previously only ran build-sbf + the TS suite), with protobuf-compiler installed for solana-program-test; added a concurrency group with cancel-in-progress to avoid stale runs piling up.

🤖 Generated with Claude Code

MagicalTux and others added 6 commits May 23, 2026 23:04
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>
@MagicalTux MagicalTux merged commit 1ccf0cd into master May 23, 2026
5 of 6 checks passed
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.

1 participant