Skip to content

feat: New Staking workflow#309

Open
0xCardiE wants to merge 60 commits into
masterfrom
feat/new_staking
Open

feat: New Staking workflow#309
0xCardiE wants to merge 60 commits into
masterfrom
feat/new_staking

Conversation

@0xCardiE
Copy link
Copy Markdown
Contributor

@0xCardiE 0xCardiE commented Apr 13, 2026

Summary

  • replace immediate staking mutations with queued stake updates for deposits, top-ups, height changes, overlay changes, withdrawals, and exits
  • simplify effective stake so an unfrozen node's effective stake is just its current balance, and a frozen node's effective stake is 0
  • add forward-looking staking preview getters with explicit lookahead semantics instead of AtRound naming
  • reconcile queued withdrawals after slashing so queued payouts cannot exceed the remaining stake
  • make redistribution claim payout atomic with the postage withdrawal
  • update deployment config and tests to cover the new staking lifecycle, withdrawal/exit behavior, payout retry behavior, winner selection stability, and Bee-facing preview semantics
  • applyUpdates reverts on frozen withdrawals/exits, post-exit operations revert QueueClosed instead of NotStaked, constructor validates wait-parameter ordering
  • fix _applyReadyUpdates so privileged callers (freezeDeposit, slashDeposit, migrateStake) can never be blocked by a frozen withdrawal — they skip frozen entries instead of reverting
  • store protocol freeze per account in freezeUntilBlock; the penalty persists across exit and stake deletion on the same registry (unstaking does not clear it)
  • freezeDeposit is monotonic — a shorter freeze never reduces an existing deadline
  • consolidate per-address state into an internal Account struct (Stake, freezeUntilBlock, UpdateQueue) with partial clears (_clearStake / _clearQueue) so freezeUntilBlock is never wiped when stake or queue is cleared

Bee Node Changes

  • Bee must no longer assume staking changes become effective immediately at transaction time. createDeposit, addTokens, increaseHeight, changeOverlay, withdraw, and exit enqueue updates with an effectiveFromRound returned by the call (also emitted in events). Until that round is reached, the queued change is not yet effective.
  • Bee should read the staking contract wait parameters instead of hardcoding timings:
    • WAIT_BASE for deposit, top-up, and height increase
    • WAIT_OVERLAY_CHANGE for overlay changes
    • WAIT_WITHDRAWAL for withdraw and exit
  • On deployed networks, WAIT_WITHDRAWAL is intended to represent roughly a 28-day delay in rounds. Local development configs may still use short values.
  • Effective state vs persisted storage: once currentRound() >= effectiveFromRound, the contract treats a queued update as effective for all view getters and for Redistribution commit/reveal — even if applyUpdates(owner) has not been called yet. nodeEffectiveStake(owner), overlayOfAddress(owner), heightOfAddress(owner), and stakes(owner) all simulate the ready prefix of the queue via _previewStake (same logic Redistribution uses). Bee must mirror this: switch overlay, height, and balance for commit/reveal as soon as the effective round is reached, not when applyUpdates succeeds.
  • applyUpdates is still required, but for different reasons: it writes matured updates to storage and executes BZZ transfers for withdrawals and exits. Token payout only happens inside applyUpdates; persisted account.stake may lag behind the effective preview until it runs. Bee should call it after the wait period (and retry on schedule) so on-chain storage and balances stay in sync, but commit/reveal must use the previewed effective state immediately once the round threshold is met.
  • If a queued withdrawal or exit has reached its effective round but the node is still frozen, applyUpdates(owner) reverts with FrozenWithdrawal(). Bee should catch this revert and retry after the freeze expires. The frozen update stays at the queue head and will execute on a subsequent applyUpdates call once the node is unfrozen.
  • Bee should continue to use Redistribution.isParticipatingInUpcomingRound(owner, depth) as the source of truth for eligibility. Bee should not try to reconstruct that decision from plain StakeRegistry getters alone, because redistribution combines staking state with round/anchor logic.
  • For future (not yet effective) queued items, the standard getters do not include them — use the lookahead getters or the effectiveFromRound from the enqueue event/return value to know when they will activate:
    • nodeEffectiveStakeLookahead(owner, lookahead)
    • overlayOfAddressLookahead(owner, lookahead)
    • heightOfAddressLookahead(owner, lookahead)
  • lookahead = 0 means "effective state for the current round context", and lookahead = 1 means "effective state one round ahead". This replaces the previous absolute-round preview naming.
  • Bee should treat nodeEffectiveStake(owner) as the live effective stake value. The previous committed/potential split is gone.
  • Bee may queue staking updates while frozen, but frozen nodes still cannot participate (nodeEffectiveStake returns 0), and queued withdrawals or exits will not execute until the freeze no longer blocks them.
  • Freeze is account-level, not stake-level: after exit or restake on the same registry, nodeEffectiveStake(owner) stays 0 until block.number > freezeUntilBlock(owner). Deploying a new registry starts with a clean slate; there is no on-chain freeze import between contracts.
  • After exit() is queued, the owner's staking queue is closed. Any subsequent call to createDeposit, addTokens, changeOverlay, increaseHeight, withdraw, or exit reverts with QueueClosed(). No further staking updates can be enqueued for that owner until the exit is applied and the queue is cleared. Bee should treat a queued exit as a terminal pending action for that stake position. Note: once the exit's effectiveFromRound is reached, nodeEffectiveStake becomes 0 and commit/reveal will revert with NotStaked() even before applyUpdates runs.
  • Bee can read UPDATE_QUEUE_MAX_LENGTH on-chain to check queue capacity before enqueuing. The queue reverts with UpdateQueueFull() when the limit is reached.
  • Bee operators migrating existing integration logic should update any workflow that previously relied on immediate post-transaction staking state. The safe sequence is:
    1. submit staking change (note the returned/emitted effectiveFromRound)
    2. wait until currentRound() >= effectiveFromRound
    3. use the new overlay/height/balance from the view getters for commit/reveal immediately
    4. call applyUpdates(owner) to persist state and settle token transfers (especially for withdrawals/exits)

Deployment Notes

  • The constructor now enforces WAIT_OVERLAY_CHANGE >= WAIT_BASE and WAIT_WITHDRAWAL >= WAIT_BASE. Deployment scripts that pass wait parameters violating this invariant will fail with InvalidWaitConfiguration().
  • Contract upgrade workflow: pause the old registry, nodes call migrateStake() to withdraw, deploy the successor registry, nodes restake there. Freeze penalties are not carried over automatically.

Redistribution Notes

  • claim() now keeps payout and round finalization atomic. If the postage payout fails, the whole claim reverts and can be retried later once the underlying issue is resolved.
  • This avoids a state where a round is marked as claimed but the winner was not paid.
  • Upcoming-round eligibility now uses the staking lookahead preview API rather than target-round naming, so the staking/redistribution interface more clearly expresses forward preview semantics.

Reference

Implement the SWIP-40 and SWIP-41 staking flow with delayed queue-based stake updates, withdrawals, and exits while keeping redistribution aligned with effective active stake.
Prevent queued stake withdrawals and exits from executing while a node is frozen or actively participating in the current redistribution round, and wire staking to the redistribution contract for the runtime check.
Prevent claims from finalizing when postage payout fails, and initialize staking with the expected redistribution contract so deployment catches linkage mismatches early.
Add direct effective stake coverage and make the two-reveal winner assertion resilient to deterministic state changes, while cleaning related test typing and lint issues.
@0xCardiE 0xCardiE changed the title feat: queue stake updates and harden redistribution payouts feat: queue stake updates Apr 13, 2026
@0xCardiE 0xCardiE self-assigned this Apr 13, 2026
0xCardiE added 11 commits April 14, 2026 00:56
Reconcile queued withdrawals after slashing and preview stake state at a specific round so upcoming-round eligibility uses the same round context as the anchor.
Prevent new stake updates from being enqueued after an exit is scheduled, and align withdrawal waits on real networks with the intended 28-day round window while keeping local settings fast.
Use overlay presence as the stake initialization check and remove the dead lastUpdatedBlockNumber field and related test assertions.
…king

Allow effective withdrawals and exits to execute without current-round participation blocking them, and remove the admin-controlled redistribution hook from staking and deployment wiring.
Clarify that queued stake preview getters are forward-looking rather than historical by switching the staking and redistribution APIs from target-round naming to explicit round lookahead semantics.
- Revert FrozenWithdrawal on frozen withdrawal/exit in applyUpdates
  instead of silently skipping
- Check _queueClosed before _previewStake so terminating queues
  revert QueueClosed instead of NotStaked
- Enforce WAIT_OVERLAY_CHANGE >= WAIT_BASE and
  WAIT_WITHDRAWAL >= WAIT_BASE in constructor
- Make UPDATE_QUEUE_MAX_LENGTH public
- Remove dead `Frozen` error (unused after FrozenWithdrawal was added)
- Merge identical `StakeState` into `Stake`, remove `_toStakeView`
- Add `_revertOnFrozen` param to `_applyReadyUpdates` so privileged
  callers (freezeDeposit, slashDeposit, migrateStake) break instead
  of reverting when a frozen withdrawal is encountered
- Move `_queueClosed` check from `_enqueueUpdate` to all six public
  callers including `createDeposit` which previously lacked it
- Update tests to expect FrozenWithdrawal revert on applyUpdates
  while frozen
0xCardiE added 10 commits April 18, 2026 20:32
Move FrozenWithdrawal revert into applyUpdates as a post-call
check instead of threading a bool through _applyReadyUpdates.
The internal function now always breaks on frozen entries.
Swap freeze and apply order in freezeDeposit so mature withdrawals
settle while the node is still unfrozen, then the freeze takes
effect for future rounds.
- Revert FrozenWithdrawal only when queue head is due and blocked
- Transfer min(scheduled amount, balance) on withdrawal apply
- Pause/unpause revert Unauthorized; zero deposit uses InvalidAmount
- InvalidWithdrawalAmount(WithdrawalAmountIssue) for withdraw rejects

BREAKING CHANGE: pause/unpause ABI error is Unauthorized not OnlyPauser;
InvalidWithdrawalAmount now takes uint8 reason.
- Document all custom errors with @notice
- BelowMinimumStake(have,need), InvalidWaitConfiguration(...),
  UpdateQueueFull(count,limit)

BREAKING CHANGE: StakeRegistry error signatures changed.
- Expose ROUND_LENGTH; rename NetworkId to networkId
- Internal _addressNotFrozen; lookahead delegates when lookahead is zero
- Reconcile queue via _applyPreviewUpdate plus WithdrawTokens storage cap
- Clear last-scheduled-round when queue empty; reconcile only after slash paths that keep stake
- Emit StakeMigrated before payout transfer from migrateStake

Tests derive round length from contract; trim redundant constants; add cases for invalid waits,
queue full, frozen apply with mismatched overlay delay, migrate event.

BREAKING CHANGE: networkId() replaces NetworkId(); ROUND_LENGTH on ABI.
0xCardiE added 9 commits May 19, 2026 12:07
Test references errors.general.queueClosed for the post-exit createDeposit
revert assertion; missing key was failing TypeScript compile in CI.
Explain that break exits the loop and leaves the blocked item at
head unapplied so FIFO is preserved for retry on a later call.
Label the three execution branches so queue application logic is
easier to follow when reading _applyStoredUpdate.
Clarifies the helper simulates queue effects in memory without
writing storage, distinct from _applyStoredUpdate.
Group stake, freezeUntilBlock, and update queue in one struct.
Use _clearStake/_clearQueue so penalties survive exit and migrate.

Refs #309
@0xCardiE 0xCardiE changed the title feat: queue stake updates feat: New Staking workflow May 21, 2026
0xCardiE added 4 commits May 22, 2026 13:02
Replace the legacy manageStake fuzz target with EchidnaStakingHarness
covering deposits, queue apply, freeze/slash, and migration. Update
system/claim mocks so the echidna suite compiles against the new staking API.
Group queue items, head, and closed flag in UpdateQueue.
Remove importFreezeUntilBlocks and its tests.

Refs #309
@lat-murmeldjur
Copy link
Copy Markdown

Some observations, some of them might be considered features, but still worth identifying.

Slashing associated issues

Current Redistribution uses freezing only. There are some possible issues that could become problematic only if slashDeposit() would be called by redistribution.

# Failure path Cheat implication Reachability
1 Queue increaseHeight(h+1) while sufficiently funded.
Then slash balance below the required stake before the update matures.
applyUpdates() applies the queued height without rechecking balance.
Node gets a height it no longer paid for.
Redistribution can treat it as higher-height / less-responsible than its stake justifies.
Slash-enabled only
2 High-height node has a queued top-up.
Full slash happens while queue exists.
Balance becomes 0, but old overlay/height stay.
Queued top-up later applies.
Fully slashed node can reactivate at the old high height with too little stake. Slash-enabled only
3 Height-0 node is slashed from MIN_STAKE to a tiny positive balance.
Height cannot be reduced below 0.
Overlay remains.
Node can remain eligible with less than the minimum stake.
Even dust can pass the stake != 0 check.
Slash-enabled only

Other possible issues

# Failure path Practical implication Reachability
4 Overlay/height change matures.
Owner does not call applyUpdates().
Staking getters still preview the matured queued state.
Redistribution reads the new virtual overlay/height.
Changes can affect commit/reveal before being materialized. Current
5 Bee follows the PR text and keeps using the old overlay until applyUpdates() succeeds.
Contract previews the matured new overlay during commit/reveal.
Bee can build commit data with the old overlay while the contract verifies with the new overlay.
Reveal can fail.
Current
6 Deployment uses 5 StakeRegistry constructor args.
Verification script supplies 6 args by inserting redistribution.address.
Deployment works, but contract verification fails because constructor args do not match deployed bytecode. Current
7 Admin calls changeNetworkId() after deployment.
Future overlays use the new network ID.
Existing overlays keep the old derivation.
Registry can contain mixed overlay derivation domains.
Admin retains a protocol-identity mutation power.
Current, admin-only
8 Account has only queued stake.
freezeDeposit() extends freezeUntilBlock.
No freeze event emits because committed stake is absent.
Indexers or Bee tooling can miss a real freeze. Current
9 Account is already frozen longer.
freezeDeposit() is called with a shorter duration.
Deadline is unchanged, but StakeFrozen can still emit.
Logs can suggest a freeze changed even when state did not change. Current
10 Account has balance 10.
slashDeposit(owner, 100) is called.
Only 10 can be removed.
Event emits 100.
Slashing analytics/accounting can overstate the actual slashed value. Slash-enabled only
11 Withdrawal is queued.
Stake is later slashed.
Withdrawal is capped to remaining balance.
Withdrawal applies to 0, but overlay/height remain.
Account can show nonzero staking metadata with zero effective stake.
Integrations may misclassify it.
Slash-enabled only
12 Contract is paused.
Matured queued update exists.
Anyone calls applyUpdates(owner) because it lacks whenNotPaused.
Pause does not stop queued state changes or withdrawal/exit transfers. Current

@awmacpherson
Copy link
Copy Markdown

Some observations, some of them might be considered features, but still worth identifying.

Slashing associated issues

Current Redistribution uses freezing only. There are some possible issues that could become problematic only if slashDeposit() would be called by redistribution.

Failure path Cheat implication Reachability

1 Queue increaseHeight(h+1) while sufficiently funded.
Then slash balance below the required stake before the update matures.
applyUpdates() applies the queued height without rechecking balance. Node gets a height it no longer paid for.
Redistribution can treat it as higher-height / less-responsible than its stake justifies. Slash-enabled only
2 High-height node has a queued top-up.
Full slash happens while queue exists.
Balance becomes 0, but old overlay/height stay.
Queued top-up later applies. Fully slashed node can reactivate at the old high height with too little stake. Slash-enabled only
3 Height-0 node is slashed from MIN_STAKE to a tiny positive balance.
Height cannot be reduced below 0.
Overlay remains. Node can remain eligible with less than the minimum stake.
Even dust can pass the stake != 0 check. Slash-enabled only

Other possible issues

Failure path Practical implication Reachability

4 Overlay/height change matures.
Owner does not call applyUpdates().
Staking getters still preview the matured queued state.
Redistribution reads the new virtual overlay/height. Changes can affect commit/reveal before being materialized. Current
5 Bee follows the PR text and keeps using the old overlay until applyUpdates() succeeds.
Contract previews the matured new overlay during commit/reveal. Bee can build commit data with the old overlay while the contract verifies with the new overlay.
Reveal can fail. Current
6 Deployment uses 5 StakeRegistry constructor args.
Verification script supplies 6 args by inserting redistribution.address. Deployment works, but contract verification fails because constructor args do not match deployed bytecode. Current
7 Admin calls changeNetworkId() after deployment.
Future overlays use the new network ID.
Existing overlays keep the old derivation. Registry can contain mixed overlay derivation domains.
Admin retains a protocol-identity mutation power. Current, admin-only
8 Account has only queued stake.
freezeDeposit() extends freezeUntilBlock.
No freeze event emits because committed stake is absent. Indexers or Bee tooling can miss a real freeze. Current
9 Account is already frozen longer.
freezeDeposit() is called with a shorter duration.
Deadline is unchanged, but StakeFrozen can still emit. Logs can suggest a freeze changed even when state did not change. Current
10 Account has balance 10.
slashDeposit(owner, 100) is called.
Only 10 can be removed.
Event emits 100. Slashing analytics/accounting can overstate the actual slashed value. Slash-enabled only
11 Withdrawal is queued.
Stake is later slashed.
Withdrawal is capped to remaining balance.
Withdrawal applies to 0, but overlay/height remain. Account can show nonzero staking metadata with zero effective stake.
Integrations may misclassify it. Slash-enabled only
12 Contract is paused.
Matured queued update exists.
Anyone calls applyUpdates(owner) because it lacks whenNotPaused. Pause does not stop queued state changes or withdrawal/exit transfers. Current

Not addressing any of the highlighted issues directly, but just to provide context I'll point out that the SWIP makes no attempt to describe how slashing would work with the update schedule because slashing is currently dead code.

Copy link
Copy Markdown
Member

@nugaon nugaon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bee should continue participating under the old effective balance and metadata while updates are still waiting in the queue. A newly requested overlay or height must not be used for commit or reveal until the delay has passed and applyUpdates(owner) has succeeded.

The contract doesn't wait for applyUpdates to be called, the new overlay/height is the effective state, even if applyUpdates was never called <-> Bee node is still sampling under the old overlay (following the doc's advice). The commit would fail or produce an incorrect proof. Bee must handle the the changes in the queue the same way as the contract: activating the property from the return block number.

Slashing allows playing the redis game with dust. If it is considered as dead code -> remove it please.

Queue must applying actions without hook functions because head is advanced after updating the items (current BZZ ERC20 contract does not have hooks so no reentrance).

Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol
uint256 public constant MIN_STAKE = 100000000000000000;
uint256 public constant UPDATE_QUEUE_MAX_LENGTH = 10;
/// @notice Maximum staking height; prevents `2**height` overflow in `MIN_STAKE * (2 ** height)`.
uint8 public constant MAX_STAKING_HEIGHT = 128;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be something like 16 since that is the max bucketDepth now.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is max bucketDepth possible to change in near future, should we give it some buffer anyway?

Comment thread src/Staking.sol
enum WithdrawalAmountIssue {
/// Amount is zero; `withdraw` only accepts positive pulls (see `exit()` for full unwind).
Zero,
/// Amount is greater than the previewed stake balance.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not dynamic? one would like to withdraw all the tokens

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what was ment, please rephrase. this enum is used for more precise error output

Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol Outdated
Comment thread src/Staking.sol
@0xCardiE
Copy link
Copy Markdown
Contributor Author

0xCardiE commented Jun 1, 2026

Bee should continue participating under the old effective balance and metadata while updates are still waiting in the queue. A newly requested overlay or height must not be used for commit or reveal until the delay has passed and applyUpdates(owner) has succeeded.

The contract doesn't wait for applyUpdates to be called, the new overlay/height is the effective state, even if applyUpdates was never called <-> Bee node is still sampling under the old overlay (following the doc's advice). The commit would fail or produce an incorrect proof. Bee must handle the the changes in the queue the same way as the contract: activating the property from the return block number.

Slashing allows playing the redis game with dust. If it is considered as dead code -> remove it please.

Queue must applying actions without hook functions because head is advanced after updating the items (current BZZ ERC20 contract does not have hooks so no reentrance).

Corrected this in PR.

Slashing was respected historically where we have dead code in place for slashing but never used it. I dont have problem with changing that and that we remove slashing part in code and in staking if we all agree on that.

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.

4 participants