From 5c7a11b38a484ecaa7049013fc544869ac98d3a0 Mon Sep 17 00:00:00 2001 From: "Andrew W. Macpherson" Date: Tue, 14 Apr 2026 07:39:52 +0900 Subject: [PATCH 1/2] SWIP-40+41 combined spec --- SWIPs/swip-40+41.md | 938 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 938 insertions(+) create mode 100644 SWIPs/swip-40+41.md diff --git a/SWIPs/swip-40+41.md b/SWIPs/swip-40+41.md new file mode 100644 index 0000000..1c967da --- /dev/null +++ b/SWIPs/swip-40+41.md @@ -0,0 +1,938 @@ +--- +SWIP: unassigned +title: Queued updates and withdrawable stake +author: Andrew Macpherson (@awmacpherson) +discussions-to: https://discord.gg/Q6BvSkCv (Swarm Discord) +status: WIP +category: Core +created: 2025-10-10 +assumes: SWIP-xx (Return to single-parameter stake record) +--- + +# Queued updates and withdrawable stake + +## Abstract + +Introduce per-owner parallel FIFO queues that add parametrised delays to all updates to stake balances and metadata (i.e. height and overlay address). This replaces the 2-round thaw imposed on stakers after any change to stake balance or metadata. Make stake fully withdrawable except while frozen, subject to a queued delay. + +## Motivation + +### Withdrawable stake + +* Swarm's staking model does not generally allow withdrawal of stake, except during smart contract migrations. +* There is an intricate notion of "excess stake" that can be withdrawn, after [SWIP-20](https://github.com/ethersphere/SWIPs/blob/master/SWIPs/swip-20.md) "improved staking." This concept substantially complicates the codebase — 1/3 of the tests for the stake registry are testing this function — +* The fact that Swarm stakers generally cannot recover their principal except via revenue makes staking a risky prospect, akin to a small venture investment. It's likely that only larger operators will be prepared to take such risks, exacerbating inequality among node operators. Conversely, the current capacity of the investment is likely too small to attract the interest of sophisticated operators. +* It also goes against the common understanding of what "stake" is. +* Making stake recoverable actually gives the system more leverage over node operators to behave well, potentially enhancing system service quality — the system can threaten penalties over the principal, instead of only over future revenue. + +An option to fully withdraw stake under typical network conditions makes staking a much more attractive, low-risk investment opportunity. Because principal is not at risk, it improves the accessibility of staking to risk-averse operators such as those with worse access to capital. It also makes the opportunity easier to compare on a like-for-like basis with other staking systems. For a more detailed analysis, see [here](https://mirror.xyz/shtuka.eth/qQnVGyNL7viiS5iLizSVL_0eTTMYGavl3Kb77XiaBxk). + +### Filtering short-termist operator behaviour + +Operators may be incentivised to make changes to their commitments based on short term signals such as market volatility, leading to excessive churn of stake positions. Imposing a wait period $X$ makes it impossible to execute a strategy based on a holding period of less than $X$. + +### Advance signalling of service changes + +Some operations — for example, height reduction or exit — result in a node's exit from or reduced financial commitment to a neighbourhood, negatively affecting the storage service. In an ideal world, any reduction of service commitment from one node would be compensated by another node arriving to take its place. However, since there is currently no reliable way to predict changes in service commitment, this hand-over cannot happen without at least a short service disruption. Introducing a mandatory "unwinding" delay before such changes come into effect, during which nodes continue to participate under their previous commitments, would provide a reliable signal allowing for smoother handoff of responsibilities between outgoing and incoming nodes. + +A mandatory wait period to exit a stake position would be familiar from many types of risky investment, where delays are a standard measure to facilitate orderly unwinding of positions. + +### Stake record update induced freeze + +The current system design imposes a 2-round "thaw" on nodes, during which they may not participate, after any change to their stake record. The intention of this freeze is to prevent consensus manipulation attacks that are possible if the node can change their stake record mid-round, after the round anchor is revealed. + +The system decides when to apply this block using an inline check in `Redistribution.commit()`, which also doubles up as checking for the "frozen" status which is applied as a penalty for consensus faults. This design has a number of flaws: + +* It is inflexible, being based on anonymous logic and a hardcoded "magic number" value; +* Overloads the `lastUpdatedBlockNumber` variable which is also used to track frozen state and record initialisation; +* Breaks the obvious semantic meaning of freeze penalty lengths by adding 2 to the number of rounds in which the node cannot participate; +* Splits responsibility for imposing "freeze-like" penalties between the `Redistribution` and `StakeRegistry` contracts; +* Blocking the node from participating and earning rewards during this time could lead to service disruptions. + +Moving the logic into a flexible, carefully designed delay system would address the first four complaints. The fifth point, an economic impact, is also easily addressed. Indeed, the defensive effect of preventing nodes from switching commitments mid-round is still achieved if the node is allowed to continue participating during the delay, but under their old balance and metadata. We find no reason to prevent the node from participating entirely. + +## Specification + +### 1. Overview and architecture + +Requests to update registered information (stake balance, height, and overlay address) and withdraw tokens are placed on a per-owner FIFO queue and executed lazily on calls to getter methods in the stake registry contract. An update is scheduled to come into effect after at least a certain number of complete rounds has elapsed since it was placed on the queue. The minimum number of rounds depends on the nature of the update. + +The 2-round participation freeze currently imposed on stakers who update their stake data is removed and replaced with a delay managed by the queue introduced here. + +The proposal introduces a new update schedule component which is responsible for tracking the schedule of updates to be applied to each owner's stake account. The update schedule is consumed only by the stake registry; it has no public facing interfaces other than events. This proposal does not define the interface that the update schedule exposes to the stake registry, nor does it specify whether the update schedule lives in its own contract or is embedded in the stake registry contract. + +The update schedule is written during the following workflows, whose semantics are affected: + +* Creating a new stake deposit +* Adding tokens to an existing stake deposit +* Changing overlay address +* Increasing height + +All of these workflows are mediated by the `manageStake()` method of `StakeRegistry`. + +One workflow is removed: + +* Decreasing height. + +New workflows are added: + +* *Draw down stake* — withdraw some tokens, but remain in the stake table with the same overlay and height commitments. +* *Exit stake* — withdraw position completely and clear the stake record. +* *Apply changes.* Apply scheduled updates from the update schedule and transfer out tokens. + +One old workflow is eliminated: + +* Withdraw "excess" stake — implemented by the function `withdrawFromStake()`, which no longer needs to exist. + +The update schedule is read when calling getter methods of the owner's stake record. + +### 2. Parameters + +#### 2.1. Queue parameters + +| Name | Value | Description | +| ------------------------- | ----- | --------------------------------------------------- | +| `UPDATE_QUEUE_MAX_LENGTH` | `10` | Maximum number of enqueued request items per owner. | + +Embedded in the update queue at deployment time. + +#### 2.2. Delay lengths + +| Name | Value | Description | +| --------------- | -------- | ------------------------------------------------------------ | +| `WAIT_BASE` | `2` | Minimum delay in rounds to impose for all operations. | +| `WAIT_WITHDRAW` | `114*28` | Minimum wait in rounds before executing stake withdrawal or exit. Must be `>= WAIT_BASE`. | + +We take `114` rounds as an approximation to one day (assuming no missed slots, it is 24 hours and four minutes). Hence the withdrawal wait period is a little over 28 days. + +Malicious changes to these variables could have the effect of trapping nodes in their positions indefinitely, so we propose that their values be embedded into the `StakeRegistry` contract at deployment time and not be modifiable by the admin. + +To change these parameters, a new `StakeRegistry` must be deployed with the new parameters passed into the constructor. The usual flow for deploying a new `StakeRegistry` applies: the old registry paused, stake migrated, a new `Redistribution` deployed with a reference to the new `StakeRegistry`, and the Redistributor role on the `Postage` contract moved from the old `Redistribution` contract to the new one. + +> **ALIGNMENT POINT.** The deployer may well wish to alter `WAIT_BASE` in future based on observations of depositor behaviour. This would be much easier if the contract provided admin-locked setter functions to change them on a deployed contract, but this potentially exposes depositors to an unaligned admin. A compromise solution is to hardcode constraints on the admin setter function, for example that it only be allowed to *decrease* the wait period, never increase. + +### 3. Interface + +#### 3.1. StakeRegistry + +##### 3.1.1. Constructor + +```solidity +// constructor(address _bzzToken, uint64 _NetworkId) +constructor( + address _bzzToken, + uint64 _NetworkId, + uint64 _waitBase, + uint64 _waitWithdraw +); +``` + +##### 3.1.2. manageStake + +Signature unchanged: + +```solidity +function manageStake(bytes32 _setNonce, uint256 _addAmount, uint8 _height) external; +``` + +Semantics changed — see §4.4.1. + +##### 3.1.3. withdraw + +```solidity +// Withdraw `amount` from stake position of `msg.sender`. +// Place withdrawal request on update queue, but do not update records or transfer tokens. +// Raise error if this would reduce available balance below minimum stake. +function withdraw(uint256 amount) public; +``` + +##### 3.1.4. exit + +```solidity +// Delete stake record of `msg.sender` from table and return all tokens. +// Place exit request on update queue, but do not immediately update records or transfer tokens. +function exit() public; +``` + +##### 3.1.5. applyUpdates + +```solidity +function applyUpdates(address _owner) public { + // pop and apply all items from _owner's update queue + // then write to storage +} +``` + +Otherwise, the interface of `StakeRegistry` is unchanged. + +#### 3.2. Events + +The function served by the events `StakeUpdated` and `OverlayChanged` are taken over by the following events, which are the responsibility (and the sole public-facing API) of the update queue component: + +```solidity +// emitted when height increase or overlay change is added to schedule +// replaces OverlayChanged +event ServiceCommitmentUpdate { + uint256 registeredFromRound; + bytes32 overlay; + uint8 height; +} + +// emitted when a deposit is made +// replaces StakeUpdated +event Deposit { + uint256 registeredFromRound; + uint256 amount; +} + +// emitted when a withdrawal is added to the schedule +event Withdrawal { + uint256 registeredFromRound; + uint256 amount; +} +``` + +#### 3.3. Optional view methods + +The queue component MAY make the following view methods part of the public interface. Doing this makes `Update` part of the public interface. + +````solidity +// OPTIONAL +// Return an in-memory copy of the list of effective updates +// for applying changes in view functions. +function peekReady(address owner) external view returns Update[]; + +// OPTIONAL +// calculate and return the minimum delay in rounds that +// would be applied for this update called by the given owner +function minimumUpdateDelay(address _owner, bytes32 _setNonce, uint256 _addAmount, uint8 _height) public view returns uint64; +```` + +### 4. Semantics + +For the purpose of describing semantics of this SWIP, introduce the following Python pseudocode model. + +**Implicit variables.** `state` is globally accessible contract state. `bzz_token` is the BZZ token contract interface. `msg.sender` is the address initiating the transaction. These are never passed as function parameters. All other variables, including `owner: EthAddress`, are always passed explicitly. + +Note that `msg.sender` is only available in functions invoked directly by an external transaction (public entry points and scheduling hooks), not in deferred application logic where the caller may be a third party. + +#### 4.1. State model + +The `Stake` field `lastUpdatedBlockNumber` is renamed to `frozenUntil`, since under this proposal the field is no longer used to freeze accounts after every update or as an initialisation check. + +```python +Stake: + overlay: bytes + balance: int + frozen_until: int # rename of lastUpdatedBlockNumber + height: int + +State: + current_block: int + current_round: int + vault_address: EthAddress # address where staked tokens are held (i.e. StakeRegistry address) + stakes: dict[EthAddress, Stake] + update_schedule: dict[EthAddress, ClosableSchedule] # see §4.2.1 +``` + +#### 4.2. Update schedule + +##### 4.2.1. Data types + +An update schedule is a sequence of update objects recorded together with the round number at which the update becomes effective. The schedule only concerns itself with timings and not semantics of updates; it may treat update objects as opaque blobs. + +A `ClosableSchedule` wraps a schedule with a flag that, once set, prevents new items from being added. Existing items can still be popped. + +```python +ScheduledUpdate: + update: Update + round: int + +ClosableSchedule: + items: list[ScheduledUpdate] # sequence order is order items were added + closed: bool = False +``` + +##### 4.2.2. Core operations + +```python +# Assume standard queue operations peek_next, peek_last, add, and pop_next are defined. +# Assume schedule is iterable as an ordinary sequence (without popping items) + +def upcoming_round() -> int: + """ + The round for which participation eligibility is currently being determined. + During Claim phase this is the next round; otherwise the current round. + Mirrors the semantics of isParticipatingInUpcomingRound in Redistribution. + """ + if current_phase_claim(): + return state.current_round + 1 + return state.current_round + +# read-only +def last_item_effective_from_round(schedule: ClosableSchedule): + return schedule.items.peek_last().round + +def add_with_minimum_wait(schedule: ClosableSchedule, update: Update, minimum_wait: int): + """ + Schedule an update. minimum_wait is the number of complete rounds that + must elapse before the updated record can be used for participation. + """ + if schedule.closed: + raise + effective_from_round = max( + state.current_round + 1 + minimum_wait, + last_item_effective_from_round(schedule) + ) + schedule.items.add(ScheduledUpdate(update=update, round=effective_from_round)) + +def pop_next_ready(schedule: ClosableSchedule) -> Update: + """ + Pop and return the next ready update. + + Uses current_round (not upcoming_round) because state application + involves token transfers, and tokens must remain exposed to freeze + penalties for the duration of any round in which the node may have + participated. + """ + next_item = schedule.items.peek_next() + if next_item.round <= state.current_round: + return schedule.items.pop_next() + else: + raise NoReadyItems + +def pop_all_and_delete(schedule: ClosableSchedule) -> list[Update]: + updates = [] + while len(schedule.items) > 0: + updates.append(schedule.items.pop_next().update) + del schedule + return updates + +# read-only +def peek_upcoming(schedule: ClosableSchedule) -> list[Update]: + """ + Return updates that are effective for the upcoming participation round. + Uses upcoming_round() so that getter methods reflect next-round values + during Claim phase, which is needed for isParticipatingInUpcomingRound. + """ + updates = [] + for item in schedule.items: + if item.round > upcoming_round(): + break + updates.append(item.update) + return updates + +# read-only +def peek_all(schedule: ClosableSchedule) -> list[Update]: + return [item.update for item in schedule.items] +``` + +##### 4.2.3. Invariants + +The update schedule is FIFO, which means that items are scheduled in the order they were added. This property is ensured since all items are added through `add_with_minimum_wait`. + +```python +# constraint +is_fifo(schedule) = all([ + item_0.round <= item_1.round + for item_0, item_1 in pairwise(schedule.items) +]) +``` + +The update schedule is *causal* if all scheduled items are scheduled in the future. A FIFO schedule may be made causal by iteratively popping all ready items. + +```python +# constraint +is_causal(schedule) = all([ + item.round > state.current_round + for item in schedule.items +]) +``` + +##### 4.2.4. Implementation notes + +When a new round starts, all scheduled updates whose round field equals the current round number come into effect. To maintain causality, these effective updates must be popped and applied. + +```python +# dynamics +def on_new_round(owner: EthAddress, schedule: ClosableSchedule): + while True: + try: + update = pop_next_ready(schedule) + apply(owner, update) + except NoReadyItems: + break +``` + +EVM does not provide a means to schedule changes to its state. A state change can only be triggered by an external actor creating a transaction. Therefore, the EVM implementation of the schedule component must tolerate being non-causal. Client implementations SHOULD take responsibility for triggering accumulated round change events via the `applyUpdates` interface. + +#### 4.3. Update types and lifecycle + +Under this proposal, updates are not applied in state immediately upon calling an update workflow. Consequently, the *update object* itself must be encoded and stored in state. The lifecycle of an update object, once created, must end with its being applied to the stake registry. + +##### 4.3.1. Type definitions + +###### 4.3.1.1. CreateDeposit + +```python +CreateDeposit: + amount: int + nonce: bytes + height: int +``` + +###### 4.3.1.2. AddTokens + +```python +AddTokens: + amount: int +``` + +###### 4.3.1.3. IncreaseHeight + +```python +IncreaseHeight: + new_height: int +``` + +###### 4.3.1.4. ChangeOverlay + +```python +ChangeOverlay: + new_nonce: bytes +``` + +###### 4.3.1.5. WithdrawTokens + +```python +WithdrawTokens: + amount: int +``` + +###### 4.3.1.6. Exit + +```python +Exit: + pass # empty struct +``` + +The full union: + +```python +Update = Union[ + CreateDeposit, + AddTokens, + IncreaseHeight, + ChangeOverlay, + WithdrawTokens, + Exit +] +``` + +##### 4.3.2. Scheduling hooks + +With the exception of height increases, update object validity is checked at the time the update is created, not when it is applied. In the case that tokens are being added, token transfer is attempted at this time and must succeed. + +```python +# assume helper methods derive_overlay(nonce) and minimum_initial_stake(height) +# are defined as in existing codebase and mainnet context + +def before_add_to_schedule(update: Update): + match type(update): + case CreateDeposit: + before_create_deposit(update) + case AddTokens: + before_add_tokens(update) + case WithdrawTokens: + before_schedule_withdraw_tokens(update) + default: + pass + +def after_add_to_schedule(update: Update): + match type(update): + case Exit: + after_schedule_exit(update) + default: + pass +``` + +###### 4.3.2.1. before_create_deposit + +```python +def before_create_deposit(update: CreateDeposit): + """ + Check deposit does not already exist and that amount exceeds threshold. + Transfer tokens or fail. + """ + if not ( + not state.stakes[msg.sender] and # deposit does not already exist + update.amount >= minimum_initial_stake(update.height) # amount exceeds threshold + ): + raise + bzz_token.transfer_from(msg.sender, state.vault_address, update.amount) +``` + +###### 4.3.2.2. before_add_tokens + +```python +def before_add_tokens(update: AddTokens): + """ + Transfer tokens. + """ + bzz_token.transfer_from(msg.sender, state.vault_address, update.amount) +``` + +###### 4.3.2.3. before_schedule_withdraw_tokens + +```python +def before_schedule_withdraw_tokens(update: WithdrawTokens): + """ + Schedule-time validity check. + """ + remaining = available_balance(msg.sender) - update.amount + if remaining < minimum_stake(state.stakes[msg.sender].height): + raise +``` + +###### 4.3.2.4. after_schedule_exit + +```python +def after_schedule_exit(update: Exit): + """ + Close the schedule to new updates. + """ + state.update_schedule[msg.sender].closed = True +``` + +##### 4.3.3. Application logic + +The semantic model of applying updates here is designed to parallel the current logic of the `manageStake` public interface. It cannot literally be implemented by calling `manageStake` since the semantics of the latter is changed to only *register* updates, not apply them. + +###### 4.3.3.1. apply (CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay) + +```python +def create_record(owner: EthAddress, update: CreateDeposit) -> Stake: + """ + Create a new record with specified parameters. + Token transfer has already occurred in before_create_deposit. + """ + return Stake( + overlay = derive_overlay(update.nonce), + balance = update.amount, + frozen_until = 0, + height = update.height + ) + +def apply(owner: EthAddress, update: Update): + """ + Apply update in state. + """ + match type(update): + case CreateDeposit: + state.stakes[owner] = create_record(owner, update) + case AddTokens: + state.stakes[owner].balance += update.amount + case IncreaseHeight: + # Apply only if new height would be greater than current height + if state.stakes[owner].height < update.new_height: + state.stakes[owner].height = update.new_height + case ChangeOverlay: + state.stakes[owner].overlay = derive_overlay(update.new_nonce) + case WithdrawTokens: + apply_withdraw_tokens(owner, update) + case Exit: + apply_exit(owner, update) + del update +``` + +###### 4.3.3.2. apply_withdraw_tokens + +```python +def apply_withdraw_tokens(owner: EthAddress, update: WithdrawTokens): + """ + Reduce liability, transfer tokens, consume update object. + Raise if owner is frozen. + """ + if not address_not_frozen(owner): + raise Frozen + state.stakes[owner].balance -= update.amount + bzz_token.transfer_from(state.vault_address, owner, update.amount) +``` + +###### 4.3.3.3. apply_exit + +```python +def apply_exit(owner: EthAddress, update: Exit): + """ + Read available balance from stake record, clear stake record, withdraw tokens, consume update. + Raise if owner is frozen. + + Semantic assumptions: this will only be applied as the LAST scheduled entry. + Therefore, there are no tokens in the onramp or offramp and no offramped tokens. + + The following identities hold: + available_balance == registered_balance == deposited_balance == stakes[owner].balance + """ + if not address_not_frozen(owner): + raise Frozen + amount = state.stakes[owner].balance + del state.stakes[owner] + del state.update_schedule[owner] + bzz_token.transfer_from(state.vault_address, owner, amount) +``` + +#### 4.4. Stake registry workflows + +##### 4.4.1. manageStake + +Instead of writing directly to the stake record, each of the four remaining management workflows creates an `Update` object of the corresponding subtype, assigns a minimum wait, and places it on the queue via the `add_with_minimum_wait` pathway. + +Calls to `manageStake` do not set `frozenUntil` (formerly `lastUpdatedBlockNumber`). New records are initialised with `frozenUntil = 0`. Account initialization checks use the `overlay` field. + +```python +def is_initialized(owner: EthAddress) -> bool: + return int.from_bytes(state.stakes[owner].overlay) != 0 + +def classify_update(owner: EthAddress, set_nonce: bytes, add_amount: int, height: int) -> list[Update]: + """ + Return subtype of update implied by parameters of call to manageStake. + + If updating an existing deposit, the call may combine multiple types. + Since the three types of updates touch different fields in the stake record, + the order of application does not matter. + """ + if is_initialized(owner): + # Mutate existing deposit + updates = [] + + # Assume height increase since this validity check is done at application time + updates.append(IncreaseHeight(height)) + + # add tokens? + if add_amount > 0: + updates.append(AddTokens(add_amount)) + + # change overlay? + new_overlay = derive_overlay(set_nonce) + if new_overlay != state.stakes[owner].overlay: + updates.append(ChangeOverlay(new_overlay)) + + return updates + else: + return [CreateDeposit(add_amount, set_nonce, height)] + + +MINIMUM_WAITS = { + CreateDeposit: WAIT_BASE, + AddTokens: WAIT_BASE, + IncreaseHeight: WAIT_BASE, + ChangeOverlay: WAIT_BASE, + WithdrawTokens: WAIT_WITHDRAW, + Exit: WAIT_WITHDRAW +} + +def manage_stake(set_nonce: bytes, add_amount: int, height: int): + """ + Classify updates into subtypes, check schedule-time validity, + transfer tokens to stake vault if necessary, + assign minimum waits, and add to schedule. + """ + updates = classify_update(msg.sender, set_nonce, add_amount, height) + for update in updates: + match type(update): + case CreateDeposit: + # First item: must initialise queue object + state.update_schedule[msg.sender] = ClosableSchedule() + before_create_deposit(update) + case AddTokens: + before_add_tokens(update) + minimum_wait = MINIMUM_WAITS[type(update)] + state.update_schedule[msg.sender].add_with_minimum_wait(update, minimum_wait) +``` + +##### 4.4.2. withdraw + +```python +def withdraw(amount: int): + update = WithdrawTokens(amount) + before_schedule_withdraw_tokens(update) + add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAW) +``` + +##### 4.4.3. exit + +```python +def exit(): + update = Exit() + add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAW) + after_schedule_exit(update) +``` + +##### 4.4.4. applyUpdates + +```python +def apply_updates(owner: EthAddress): + schedule = state.update_schedule[owner] + while True: + try: + update = pop_next_ready(schedule) + apply(owner, update) + except NoReadyItems: + break +``` + +##### 4.4.5. Getter methods + +With the exception of `addressNotFrozen` and `lastUpdatedBlockNumberOfAddress`, all view methods that read the stake record have changed semantics. + +During the Claim phase of round R, an update scheduled to come into effect in round R+1 is visible via getters (so that `isParticipatingInUpcomingRound` reports correctly) but is not *poppable* (so tokens cannot be transferred out until round R+1, remaining exposed to freeze penalties for round R). + +```python +def stakes(owner: EthAddress) -> Stake: + record = state.stakes[owner].clone() + for update in peek_upcoming(state.update_schedule[owner]): + record = apply_to(record, update) + return record +``` + +Three other public API methods are defined by calling into the getter function above instead of reading the stakes table directly. + +* `nodeEffectiveStake` +* `overlayOfAddress` +* `heightOfAddress` + +##### 4.4.6. migrateStake + +`migrateStake` is used when the contract is paused so that stake can be moved to a new deployment. It must immediately transfer all deposited tokens, including those still on the onramp. The simplest way to achieve this is to apply all pending updates before transferring the registered balance. + +```python +def migrate_stake(): + for update in pop_all_and_delete(state.update_schedule[msg.sender]): + apply(msg.sender, update) + bzz_token.transfer_from(state.vault_address, msg.sender, state.stakes[msg.sender].balance) + del state.stakes[msg.sender] +``` + +#### 4.5. Accounting + +We introduce the following helper methods for internal accounting: + +```python +def offramping_balance(owner: EthAddress): + return sum([ + item.update.amount + for item in state.update_schedule[owner] + if type(item.update) == WithdrawTokens + and item.round > state.current_round + ]) + +def offramped_balance(owner: EthAddress): + """ + Tokens that have been deregistered and are ready for withdrawal. + """ + return sum([ + item.update.amount + for item in state.update_schedule[owner] + if type(item.update) == WithdrawTokens + and item.round <= state.current_round + ]) + +def available_balance(owner: EthAddress): + return stakes(owner).balance - offramping_balance(owner) +``` + +The implementer MAY make these functions available as `view` methods in the public API. + +#### 4.6. Redistribution + +Since the semantics of the `StakeRegistry` getter functions has changed, so too have the semantics of the three `Redistribution` functions that call them: `commit`, `reveal`, and `isParticipatingInUpcomingRound`. + +Since the 2 round cool-off after a call to `manageStake` has been replaced with a delay managed by the update queue, the following check in the logic of `commit()` is no longer needed and should be removed: + +```solidity + if (_lastUpdate >= block.number - 2 * ROUND_LENGTH) { + revert MustStake2Rounds(); + } +``` + +(see https://github.com/ethersphere/storage-incentives/blob/v0.9.4/src/Redistribution.sol#L303). + +Since `nodeEffectiveStake` is zero for a frozen node, frozen nodes cannot participate even when this check is removed. Nonetheless, the implementer MAY wish to add a freeze status check to `commit()` so that participation fails early for a frozen node, saving on computation. + +#### 4.7. Height decrease + +Height decreases on an active stake position are disallowed. + +> **ALIGNMENT POINT.** Height decreases can be blocked in a few different ways: +> +> 1. Block at scheduling time. This requires lookahead on all pending updates to see if the new height would be valid at application time, and increases the complexity of the queue. +> 2. Fail silently at application time. When applying an update that would decrease the height, simply do not honour the height change. From the perspective of the current interface, this is the lowest profile approach. However, it violates the principle that validity is checked at scheduling time and that scheduled updates will always be applied, which could lead to subtle errors in client implementations. +> 3. Block at interface level. Make the public API for changing height on an existing deposit take an unsigned integer *height increase* instead of the new height. Probably the cleanest solution but requires another public API change. + +## 5. Rationale + +### Queue design + +* *One queue.* All types of updates for all stake owners are considered to be part of one queue. While some queue designs may allow for handling different owners or different update types in isolation, others — such as a global churn rate limiter — require tracking global state. To future proof the queue interface against possible changes to queue design, other components of the system must treat the entire network-wide queue as a single black box. + +* *Separate UpdateQueue contract.* We propose the update queue be maintained in a separate contract from the Stake Registry for the sake of maximising modularity and isolating parts of deployments from unrelated future upgrades. + + For the sake of gas efficiency, the UpdateQueue contract could be inlined into the StakeRegistry. However, we find this to be a premature optimisation that gives up modularity for the sake of gas fees that are basically insignificant (millionths of a dollar) in practice. + +* *2 round thaw.* The 2 round thaw currently implemented (but not fully documented) in the `commit()` method of the Redistribution contract is absorbed into this queue. The delay length is preserved as the `WAIT_BASE` parameter. However, unlike in the old model, participation is still allowed during the thaw period — but under the old stake position. + +* *Signalling.* To act as a signal, node operators must be able to easily index enqueued updates along with the round number at which they come into effect. Since the `UpdateQueue` contract is responsible for tracking when updates come into effect, the events used for indexing must be emitted from there. There is no need to emit an event when the update is actually applied in state, which is inconsequential. + +* *Per-neighbourhood delay scaling.* It may make sense to adjust the delay of changes depending on the before and after population of each neighbourhood affected by the change. The core example is to reduce delay for nodes leaving a neighbourhood with large population (and in the case of overlay change, entering one with small population). This would require the queue system to be able to estimate replication depth and enlarges the design space considerably, so we omit it from the present proposal. + +* *Maximum queue length.* In principle, an update queue could grow so long that it cannot be emptied in a single block. Therefore, there should be a maximum number of updates that can be held in the queue for each owner. It probably won't cause a big problem if the number is quite small, e.g. 10. + + An alternative approach would be to internally compose operations using an internal representation closed under composition. While we can imagine ways to do this for the set of operations the queue is currently expected to process, it would complicate the process of adding any new types of operation to the queue or changing the queue algorithm. A simple maximum queue length is easy to implement, universal, and unlikely to raise any serious objections. + +* *Staker commitments.* Staker commitments, i.e. transfers to the stake registry, must be binding for the staker at the time the update is requested. The queue subsystem must be able to report up-to-date commitments. The effect of the new commitment (i.e. the new balance can be used in Redistribution) does not apply until after the delay. + +* *Liability tracking.* The proposed changes mean that the balance recorded under a given `owner` in the Stake Registry does not always equal the total amount of BZZ deposited by that owner (net of withdrawals). Rather, the records of liabilities of the Stake Registry to a given owner are split between the Registry itself and the Update Queue. These records control what can be withdrawn, so `migrateStake` must either block on not-yet-active updates, or fast track and apply them. + +* *Manual queue triggering.* To preserve the getter interface of the `StakeRegistry` and make minimal changes to `Redistribution`, getter methods do not actually apply effective updates in place. However, the contract still needs a way to apply updates in place, or the queue will grow without bound, hence the `applyUpdates` endpoint. It is expected that clients will trigger `applyUpdates` regularly, either immediately after a new update comes into effect or before calling `Redistribution.commit()` during the next round that the overlay comes into proximity. + +* *Update classification.* To apply different delays to different updates, updates need to be classified into types to be processed by the queueing system. Currently, the logic of `manageStake` implicitly classifies updates by the four non-reverting branches it takes, according to the independent predicates `(_addAmount > 0)` or `(_previousOverlay != _newOverlay)`. In the interests of allowing `UpdateQueue` to concern itself exclusively with queueing semantics, and not with staking, we propose that the responsibility of semantic classification of updates remain with `manageStake`, while `UpdateQueue` deals with sizes of updates. + +* *Update encoding.* There are two basic approaches to recording the data of an "update" in the UpdateQueue: + + 1. Record the new values to be applied in a struct. + 2. Directly encode the calldata of the call that will be made. + + Option (2) is future-proof in the sense that the same encoding will make sense if new types of update are introduced. OTOH it is less suitable for introspection than (1). We argue that the schedule itself should not be doing any introspection — it simply keeps track of *when* each update should be applied, and it is the caller's responsibility to hand it enough data to make that call. From this perspective, the opacity of an encoded call is also an advantage. + + The matter of encoding is relevant to the interface because events must be emitted for each update. + +### Withdrawal design + +* *Withdraw/exit separate public methods from `manageStake`*. The `manageStake()` endpoint is already overloaded with five different workflows (create deposit, top up deposit, change overlay, increase height, decrease height). It contains four conditional branches and touches every part of the stake record. Withdrawal or exit are logically distinct actions from any of these, and there is no reason to bundle them into the same function. + +* *Withdraw and exit separate methods.* Exit could have been implemented as simply "withdraw down to zero." Optionally, the method could decide to clear the stake record on a call that would reduce balance to zero. We chose this design because it matches the natural split from a user decisioning perspective between a "drawdown" and "exit" action, the zero-parameter `exit()` method matches the simplicity of the decision itself (and is more gas efficient than `withdraw()`), and to minimise conditional branching and overloading of individual methods. + +* *Frozen accounts cannot withdraw.* Swarm Protocol uses participation freezes to penalise consensus or commit-reveal faults. If the owner could draw down or exit the frozen stake position, he could reduce the impact of the freeze penalty; worse, the funds can be redeposited in the same neighbourhood to recover the income lost to freezing. Therefore, frozen nodes cannot be allowed to withdraw funds during the freeze period. + +* *Same wait time for drawdowns and exits.* In principle, a drawdown is less impactful than an exit: the former doesn't necessarily entail a reduced service to the network. A drawdown therefore *could* reasonably be given a shorter wait time than an exit, at the cost of introducing one additional protocol parameter. In practice, we don't see a clear enough benefit to allowing faster drawdowns to justify the added complexity. This is especially true given the current low minimum stake, which renders drawing down to the minimum roughly payoff equivalent to exiting. + +### Concurrency + +* If actions are anything other than instantaneous and atomic, we need to deal with concurrency — that is, an update being requested while another is waiting in the queue. +* *In-order execution.* + * Insisting on in-order execution means that actions with short delays (e.g. topping up) can be held up by actions with longer delays (e.g. withdrawal). This might not be necessary. + * On the other hand, allowing out-of-order execution will probably make the analysis much more complicated. It will be harder to use the queue state to make a forecast and to implement lookahead. +* *Request cancellation.* Requires a way to specify which request should be cancelled, and again substantially complicates making use of the information benefits of a public queue. It is simpler and more elegant not to allow cancellations. + +### Effect of waiting status on other components + +* *Price oracle.* For the purposes of adjusting storage prices, the reveal counter could discount nodes currently waiting to exit a neighbourhood. The basic reason to do this is to allow prices to pre-emptively respond to an upcoming decrease in supply, and hence mean replication rate. However, there are quite a lot of questions about on what principles the design of this feature should be based and how it should be implemented. + + * Local or global: should we attempt to introduce the discount when a node participates, or track node height reductions with a global counter? + * In the other direction, should prices pre-emptively decrease in response to height increases and new nodes? + * What price manipulations possibilities does this open up? What is the effect of enqueueing strings of updates? + + And so on. Moreover, the way that node balancing and replication rate is tracked may change substantially in the near future with something along the lines of SWIP-39. Therefore, we'd rather defer implementing price oracle pre-emption. + +* *Reward sharing.* For the advance signalling function of an exit queue to work, nodes must be incentivised to continue operating while they are in the queue. Hence, they must be able to continue participating in reward sharing (and penalties) using their previous participation metadata while waiting. Accordingly, they must participate in all the activities that qualify them for reward sharing, i.e. reserve consensus and storage and density proofs. + +* *Freezing.* Under the current system, frozen nodes are not allowed to mutate stake records. The effect of this is that if a frozen node decides they wish to update their stake record, they must wait until the freeze ends, execute the update, and wait another 2 rounds to participate again. In other words, as well as blocking participation, freeze penalties delay executing changes to the stake record. This behaviour appears to be undocumented (cf. https://docs.ethswarm.org/docs/concepts/incentives/redistribution-game#penalties), and it's not clear if it's important. + + This SWIP does not propose to change this behaviour, but we note that its effects are exacerbated by introducing longer record update delays. A node operator who decides while frozen to update their stake record must wait the update delay sequentially after the freeze period. On the other hand, if an update is enqueued and *then* the node gets frozen, the freeze period and the update delay run concurrently. + + Applying effective updates in state is purely a gas management measure, and does not affect any values that can be read from the contract. + +* *Pausing.* See §4.4.6. + +### Contract and parameter upgrades + +Can the reference to `UpdateQueue` maintained by `StakeRegistry` be changed by the admin? With a delay? Broadly speaking, we see three approaches: + +1. Reference is immutable. To change the update queue logic, a new stake registry must be deployed. +2. Reference is instantly mutable. Admin can burn stake by imposing infinite delays. +3. Reference is mutable with a delay, emitting an event. Stakers may withdraw if they do not want to be subject to the new queue logic. + +Under the current implementation, the admin can lock all stake indefinitely, effectively burning it, by never pausing the contract. The proposed changes should not make this attack worse and expose stake to a malicious admin. + +Can multiple `StakeRegistry` deployments reference a single `UpdateQueue`? *No*, because that would screw everything up. Write changes to `UpdateQueue` must be permissioned to a unique `StakeRegistry`. (`UpdateQueue` does not need to maintain a reference to `StakeRegistry`, only a commitment.) + +An intermediate option is that the *logic* of `UpdateQueue` is immutable, but the *delay parameters* can be changed. This doesn't improve much, as it still gives the admin to lock stake indefinitely. + +## 6. Security implications + +* The update queue subsystem takes ownership, in the form of `WAIT_BASE`, of the 2 round metadata update delay currently found in the initial validation checks of the `Redistribution:commit()` call. A top-up or deposit delay of at least until the end of the current round is required to prevent shadow stake attacks. No immediate changes to security model for shadow stake or penalty evasion are implied by the current proposal, but care needs to be taken in future to preserve the `WAIT_BASE` minimum. +* In the proposed access control model, anyone may trigger processing of valid updates from anyone else's queue. Since updates cannot be cancelled and would be processed anyway before the state can be used in redistribution, this is harmless. +* We expect that withdrawable stake will result in more money being held in the stake registry contract, which accordingly scales the security concerns. + +* *Instant unfreeze.* Frozen accounts cannot be allowed to withdraw. Otherwise, a depositor could evade freezing by simply withdrawing and opening a new account. + +* *Consensus penalty evasion.* If withdrawals are allowed in the middle of a round, a depositor who commits but does not reveal, or one who reveals but considers the risk of being found in disagreement too high, can evade Non-revealed or Disagreement penalties by quickly withdrawing. To make these penalties effective, withdrawals in the middle of the round should therefore be restricted, at least until the end of that round. + + Either of the following restrictions would prevent penalty evasion: + + * Preventing withdrawal if the owner has already committed in the current round. This requires the withdraw function to carry a reference to the Redistribution contract. + * A withdrawal delay of at least one round. + +* *Shadow stake.* Withdrawals enable a strategy in which a large amount of stake is temporarily deposited in order to skew the leader election contest. Currently, the stake update cool-off period embedded in the `commit()` method prevents this strategy from being carried out after the round anchor is revealed. Upgrades should take care to preserve some thawing period after depositing (including topping up) stake. + +## 7. Economic implications + +*For new nodes.* The option to withdraw makes staking significantly more attractive: lower risk, higher reward per unit TVL. Indeed, the value of a position under these changes is bounded below by the liquidation value of the staked assets, suitably discounted by the withdrawal delay and the risk of freezes (which is very low because an operator that wishes to withdraw can reliably avoid freezes by refraining from participating). Hence, we expect that this change would lead to a significantly larger TVL owned by a more diverse set of stakers, including those with lower risk appetites. + +*For existing nodes.* Since these changes would be deployed in a new stake registry contract and old stake migrated, any stake "trapped" in the old registry (attached to nodes whose operators wish to wind down their operations) will simply be withdrawn and never redeposited in the migration. There is no reason to expect any stake outflow in excess of what would normally be seen in a migration. + +For nodes that do choose to make the migration, staking has just become more attractive, by the same logic as above. Stake inflows associated to new nodes increases the amount of stake required to break even on revenue share. We therefore expect to see substantial increases in stake balance among migrating nodes. + +*Dynamics.* Allowing stake outflows will likely significantly increase the amount of activity in the staking pool. Nodes that have stopped operating are incentivised to signal that they have done so by withdrawing their stake. Onchain stake records become a more accurate predictor of how much stake will participate in redistribution. We recommend the community closely monitor inflows and outflows. + +*Pricing stake positions.* Stake inflows and outflows can be viewed as trade in both directions between unstaked BZZ and stake positions. Allowing trade in both directions allows these products to be priced efficiently. Because stake positions and BZZ can be converted on a 1-1 basis with a delay, pricing the trade is a matter of maturity transformation. We expect that the TVL of Swarm stake will grow until the marginal yield earned by a staked BZZ token balances exactly against the liquidity penalty implied by the withdrawal delay. The volume of stake churn (inflows + outflows) is a measure of the strength of the signal pricing staked BZZ as an asset class. + +*Signalling drawdowns and exits.* The enforced withdrawal delay means that nodes must signal their exit or drawdown a number of rounds before executing it and recovering their tokens. During this time, it is expected that they continue participating and earning rewards. The signal is not entirely reliable: if the operator changes their mind and decides they wish to stay while waiting to exit, they can simply re-enter shortly after exiting with only a short downtime penalty. That is, while the signal is necessary for a drawdown or exit to occur, it doesn't guarantee that the same operator will remain drawn down or exited for any significant length of time. + +*Filtering drawdowns and exits.* The enforced withdrawal delay prevents nodes from drawing down their stake in order to deploy capital into a short-lived opportunity — certainly, at least, an opportunity expected to last for less than the delay period. This includes the case of drawing down capital to deploy it onto another node, but note that an overlay change can bypass this delay to achieve a similar effect. Depending on the length of the delay, this can have a substantial damping effect on stake churn. We recommend that stake churn be monitored with the goal of estimating its relation to exit delay length. + +*Economic security.* Topping up or drawing down stake increases or reduces the economic security staked against consensus penalties, namely, failure to reveal or faulty reveal (in disagreement with the consensus leader). Since the expected effect of this proposal is a substantial increase in stake deposits, a corresponding increase in economic security is also anticipated. As long as the distribution of stake across the address space remains stable, stake churn has little effect on economic security in and of itself. + +*Quality of service.* Node inflows and outflows increase and reduce the service quality of the network, respectively. Rapid changes in node population, even if the overall count remains stable, impacts service as new nodes must spend time syncing their reserve, a process that takes hours per neighbourhood. New node entries also have a resource impact on their neighbours, who must use bandwidth serving pullsync request, though we believe that in the current network configuration this effect is modest. The cost of entry and exit should therefore be carefully tuned to limit node churn and achieve a desired service quality. + +The main method we have to control the cost of entry and exit is the minimum delay period. Concretely, the larger of the deposit and withdrawal delays puts a hard limit on how often a given BZZ token can be moved in and out of stake. + +* A long drawdown delay makes depositing costly by attaching it to a long commitment to lock capital in Swarm. +* A long exit delay makes entry costly by attaching it to a long commitment to provide storage to Swarm (except the commitment is only really there if stake is also locked). +* A long deposit delay makes drawdowns costly by blocking the tokens from earning Swarm revenue. (This applies if tokens must be deposited at the start of the wait period.) +* A long entry delay makes exits more costly by blocking resources from earning Swarm revenue. (If resources must be committed to participate at the start of the wait period, which is really not the case without slashing if the node cannot earn revenue.) + +What cannot be achieved with an exit delay is to slow down the rate of nodes exiting with the intention to stay exited for a longer period. The benefit is in (i) filtering out short-termist stake churn, and (ii) discouraging mercenary capital in the first place. + +*Cheap option.* If withdrawal delay is long relative to deposit delay, a depositor can acquire a "cheap periodic option" to exit by immediately enqueueing a withdrawal after his deposit. The option can be made more expensive by increasing the proportion of time the deposit must spend not earning rewards. + +## 8. Interactions with other proposals + +* **SWIP-39.** Automatic address allocation is only binding if it is economically infeasible to reroll many times to achieve a desired prefix. Under the proposed scheme, withdrawals are instant and almost free, so they do not add any Sybil resistance to this scheme. Sybil-resistance could be introduced in a controlled manner by adding a tax or delay to withdrawals. Current versions of automatic neighbourhood assignment call for a delayed commit/execute scheme to be allocated a neighbourhood after staking. The present update queue provides a subsystem to implement this delay. + + Changes to the way that balancing and node count are tracked could have implications for how the price oracle is adjusted, which would interact with variants of this proposal that use the queue to pre-empt price changes. + +* **Non-custodial stake registry.** A non-custodial model for the stake registry separates the actions of *deregistering* stake, which disencumbers assets from their commitment to participation in the redistribution contest, and *withdrawing,* which actually transfers the assets out of the target account. In a non-custodial model, all references to "withdrawals" in the current proposal should apply specifically to deregistrations rather than moving assets, and its conditionals should be implemented within the redistributor contract which checks the conditions when the owner requests the lock be released. + +* *Self-custodial/upgradable stake registry.* An upgradable stake registry change would not need the `migrateStake` endpoint and possibly separate balance and participation metadata management into different contracts. + + When a change to the queue design occurs, metadata updates already waiting in the queue should ideally continue be processed under the old queue logic. If the queue state is part of the Stake Registry contract, there is no way to protect it from arbitrary updates. Thus the queue ought to be part of a new contract accessible by the Redistributor. + + If a self-custodial vault model is used to protect user actions from malicious registry upgrades, a separate Queue contract could facilitate protection of withdrawals by taking over a claim on the funds marked for withdrawal from the Registry, before ultimately returning it to the owner when the withdrawal is ready. It would then be impossible for a Registry upgrade to affect the winding down of the claim. + +## Alternative approaches + +* **Withdrawal delay.** There are a few reasons that it may be desirable to enforce a delay on withdrawing assets after the intention to withdraw has been telegraphed. In this case, at least two actions would be required to withdraw: commit to withdraw, and execute withdrawal. For simplicity, this proposal specifies a single-action instant withdrawal. +* **Withdrawal queue.** In a system with a withdrawal delay, impose a hard limit on the number of nodes that can be waiting to exit at any one time. This is how Ethereum validator exits work. So far, we haven't established a need for this feature, which adds more complexity. +* **Address-based withdrawal restrictions.** Some discussions floated the idea of limiting withdrawals of nodes whose absence would cause the overlay address population to become "unbalanced" (for example, withdrawals from neighbourhoods that are already underpopulated). +* **Withdrawal tax.** Instead of being fully withdrawable, stake exits incur a fixed burn of some amount. The amount can be used to control the cost of exiting and re-entering, which in turn could be deployed as a mechanism to improve network stability. + +## Implementation notes + +* These changes require a new StakeRegistry contract to be deployed. Because the StakeRegistry reference is hardcoded in the Redistribution contract, a new Redistribution contract would also need to be deployed. Access to the Postage contract would be granted to the new Redistribution contract and revoked from the old one. + +* In the current release model of bee, a new version of bee is required to integrate the new contracts. In principle, a bee node that is unmodified other than to update its reference to the contracts could continue to stake and participate as before. However, the interface would need to be updated to be able to integrate with withdrawal or exit, and forecasts of revenue share would need to reflect the simplified logic. + +## Testing strategy + +TODO From 52c5c978fb57923edd808ec6b1e7ff8eda2c54e8 Mon Sep 17 00:00:00 2001 From: "Andrew W. Macpherson" Date: Thu, 14 May 2026 16:59:43 +0900 Subject: [PATCH 2/2] Update SWIP-40+41 to align with implementation. manageStake split into separate per-workflow methods. Lookahead methods semantics and interface defined. Interaction of update enqueue and freeze clarified. --- SWIPs/swip-40+41.md | 351 ++++++++++++++++++++++++++------------------ 1 file changed, 210 insertions(+), 141 deletions(-) diff --git a/SWIPs/swip-40+41.md b/SWIPs/swip-40+41.md index 1c967da..d1c0a9b 100644 --- a/SWIPs/swip-40+41.md +++ b/SWIPs/swip-40+41.md @@ -68,7 +68,7 @@ The update schedule is written during the following workflows, whose semantics a * Changing overlay address * Increasing height -All of these workflows are mediated by the `manageStake()` method of `StakeRegistry`. +Each of these workflows is mediated by a dedicated public method of `StakeRegistry`: `createDeposit`, `addTokens`, `changeOverlay`, and `increaseHeight` respectively. One workflow is removed: @@ -98,10 +98,11 @@ Embedded in the update queue at deployment time. #### 2.2. Delay lengths -| Name | Value | Description | -| --------------- | -------- | ------------------------------------------------------------ | -| `WAIT_BASE` | `2` | Minimum delay in rounds to impose for all operations. | -| `WAIT_WITHDRAW` | `114*28` | Minimum wait in rounds before executing stake withdrawal or exit. Must be `>= WAIT_BASE`. | +| Name | Value | Description | +| ---------------------- | -------- | ------------------------------------------------------------ | +| `WAIT_BASE` | `2` | Minimum delay in rounds for deposits, top-ups, and height increases. | +| `WAIT_OVERLAY_CHANGE` | `2` | Minimum delay in rounds for overlay changes. Must be `>= WAIT_BASE`. | +| `WAIT_WITHDRAWAL` | `114*28` | Minimum wait in rounds before executing stake withdrawal or exit. Must be `>= WAIT_BASE`. | We take `114` rounds as an approximation to one day (assuming no missed slots, it is 24 hours and four minutes). Hence the withdrawal wait period is a little over 28 days. @@ -123,19 +124,31 @@ constructor( address _bzzToken, uint64 _NetworkId, uint64 _waitBase, - uint64 _waitWithdraw + uint64 _waitOverlayChange, + uint64 _waitWithdrawal ); ``` -##### 3.1.2. manageStake +##### 3.1.2. Staking operations -Signature unchanged: +The `manageStake()` method is replaced by four separate public methods, one for each update type: ```solidity -function manageStake(bytes32 _setNonce, uint256 _addAmount, uint8 _height) external; +// Create a new stake deposit. Reverts if a deposit already exists. +function createDeposit(bytes32 _setNonce, uint256 _amount, uint8 _height) external; + +// Add tokens to an existing deposit. +function addTokens(uint256 _amount) external; + +// Increase height on an existing deposit. _height is the new absolute height; +// reverts if _height is less than the current (planned) height. +function increaseHeight(uint8 _height) external; + +// Change the overlay on an existing deposit. +function changeOverlay(bytes32 _setNonce) external; ``` -Semantics changed — see §4.4.1. +Semantics — see §4.4.1. ##### 3.1.3. withdraw @@ -167,46 +180,38 @@ Otherwise, the interface of `StakeRegistry` is unchanged. #### 3.2. Events -The function served by the events `StakeUpdated` and `OverlayChanged` are taken over by the following events, which are the responsibility (and the sole public-facing API) of the update queue component: +The events `StakeUpdated` and `OverlayChanged` are replaced by one event per update type: ```solidity -// emitted when height increase or overlay change is added to schedule -// replaces OverlayChanged -event ServiceCommitmentUpdate { - uint256 registeredFromRound; - bytes32 overlay; - uint8 height; -} +// emitted when a new deposit is created +event DepositCreated(address indexed owner, uint64 registeredFromRound, uint256 amount, bytes32 overlay, uint8 height); -// emitted when a deposit is made -// replaces StakeUpdated -event Deposit { - uint256 registeredFromRound; - uint256 amount; -} +// emitted when tokens are added to an existing deposit +event TokensAdded(address indexed owner, uint64 registeredFromRound, uint256 amount); -// emitted when a withdrawal is added to the schedule -event Withdrawal { - uint256 registeredFromRound; - uint256 amount; -} +// emitted when overlay change is added to schedule +event OverlayChanged(address indexed owner, uint64 registeredFromRound, bytes32 overlay); + +// emitted when height increase is added to schedule +event HeightIncreased(address indexed owner, uint64 registeredFromRound, uint8 height); + +// emitted when a withdrawal or exit is added to the schedule +event Withdrawal(address indexed owner, uint64 registeredFromRound, uint256 amount); ``` -#### 3.3. Optional view methods +#### 3.3. Lookahead view methods + +In addition to the existing getter methods (`nodeEffectiveStake`, `overlayOfAddress`, `heightOfAddress`), which return the effective state at the current round, the following view methods return the state that would be effective `_lookahead` rounds from now: -The queue component MAY make the following view methods part of the public interface. Doing this makes `Update` part of the public interface. +```solidity +function nodeEffectiveStakeLookahead(address _owner, uint64 _lookahead) public view returns (uint256); +function overlayOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (bytes32); +function heightOfAddressLookahead(address _owner, uint64 _lookahead) public view returns (uint8); +``` -````solidity -// OPTIONAL -// Return an in-memory copy of the list of effective updates -// for applying changes in view functions. -function peekReady(address owner) external view returns Update[]; +These are used by the `Redistribution` contract to evaluate participation eligibility for the current or next round — see §4.6. -// OPTIONAL -// calculate and return the minimum delay in rounds that -// would be applied for this update called by the given owner -function minimumUpdateDelay(address _owner, bytes32 _setNonce, uint256 _addAmount, uint8 _height) public view returns uint64; -```` +The parameter is a lookahead (rounds into the future) rather than an absolute target round because past-round queries cannot be answered safely. The base stake record `state.stakes[owner]` accumulates all applied updates without history, so a query with `target_round < current_round` would reflect updates that had already been baked into the record even though they were not yet effective at `target_round`. Moreover, whether any ready-but-not-yet-popped update appears in the queue or in the base record depends on when `applyUpdates` happened to be called — which is third-party-triggered and therefore manipulable. Restricting the interface to non-negative lookaheads makes the function well-defined and deterministic. ### 4. Semantics @@ -259,29 +264,21 @@ ClosableSchedule: # Assume standard queue operations peek_next, peek_last, add, and pop_next are defined. # Assume schedule is iterable as an ordinary sequence (without popping items) -def upcoming_round() -> int: - """ - The round for which participation eligibility is currently being determined. - During Claim phase this is the next round; otherwise the current round. - Mirrors the semantics of isParticipatingInUpcomingRound in Redistribution. - """ - if current_phase_claim(): - return state.current_round + 1 - return state.current_round - # read-only def last_item_effective_from_round(schedule: ClosableSchedule): return schedule.items.peek_last().round def add_with_minimum_wait(schedule: ClosableSchedule, update: Update, minimum_wait: int): """ - Schedule an update. minimum_wait is the number of complete rounds that - must elapse before the updated record can be used for participation. + Schedule an update. minimum_wait is the number of rounds before + the update comes into effect. The update is effective from the + first round >= current_round + minimum_wait that does not precede + any already-scheduled item. """ if schedule.closed: raise effective_from_round = max( - state.current_round + 1 + minimum_wait, + state.current_round + minimum_wait, last_item_effective_from_round(schedule) ) schedule.items.add(ScheduledUpdate(update=update, round=effective_from_round)) @@ -290,10 +287,9 @@ def pop_next_ready(schedule: ClosableSchedule) -> Update: """ Pop and return the next ready update. - Uses current_round (not upcoming_round) because state application - involves token transfers, and tokens must remain exposed to freeze - penalties for the duration of any round in which the node may have - participated. + Uses current_round because state application involves token transfers, + and tokens must remain exposed to freeze penalties for the duration + of any round in which the node may have participated. """ next_item = schedule.items.peek_next() if next_item.round <= state.current_round: @@ -309,15 +305,32 @@ def pop_all_and_delete(schedule: ClosableSchedule) -> list[Update]: return updates # read-only -def peek_upcoming(schedule: ClosableSchedule) -> list[Update]: +def peek_ready(schedule: ClosableSchedule) -> list[Update]: + """ + Return updates that are effective at the current round. + Used by getter methods (stakes, nodeEffectiveStake, etc.). + """ + updates = [] + for item in schedule.items: + if item.round > state.current_round: + break + updates.append(item.update) + return updates + +# read-only +def peek_lookahead(schedule: ClosableSchedule, lookahead: int) -> list[Update]: """ - Return updates that are effective for the upcoming participation round. - Uses upcoming_round() so that getter methods reflect next-round values - during Claim phase, which is needed for isParticipatingInUpcomingRound. + Return updates that would be effective `lookahead` rounds from now. + Used by *Lookahead view methods. + + The parameter is a non-negative offset from the current round + (enforced by uint64 in the Solidity interface). Past-round queries + are not supported — see §3.3. """ + target_round = state.current_round + lookahead updates = [] for item in schedule.items: - if item.round > upcoming_round(): + if item.round > target_round: break updates.append(item.update) return updates @@ -431,7 +444,7 @@ Update = Union[ ##### 4.3.2. Scheduling hooks -With the exception of height increases, update object validity is checked at the time the update is created, not when it is applied. In the case that tokens are being added, token transfer is attempted at this time and must succeed. +Update object validity is checked at the time the update is created, not when it is applied. In the case that tokens are being added, token transfer is attempted at this time and must succeed. ```python # assume helper methods derive_overlay(nonce) and minimum_initial_stake(height) @@ -443,6 +456,8 @@ def before_add_to_schedule(update: Update): before_create_deposit(update) case AddTokens: before_add_tokens(update) + case IncreaseHeight: + before_increase_height(update) case WithdrawTokens: before_schedule_withdraw_tokens(update) default: @@ -482,7 +497,45 @@ def before_add_tokens(update: AddTokens): bzz_token.transfer_from(msg.sender, state.vault_address, update.amount) ``` -###### 4.3.2.3. before_schedule_withdraw_tokens +###### 4.3.2.3. before_increase_height + +```python +def planned_height(owner: EthAddress) -> int: + """ + Return the height that would be in effect after all queued updates + (including not yet effective ones) have been applied. + """ + record = state.stakes[owner].clone() + for queued in peek_all(state.update_schedule[owner]): + record = apply_to(record, queued) + return record.height + +def planned_balance(owner: EthAddress) -> int: + """ + Return the balance that would be in effect after all queued updates + (including not yet effective ones) have been applied. + """ + record = state.stakes[owner].clone() + for queued in peek_all(state.update_schedule[owner]): + record = apply_to(record, queued) + return record.balance + +def before_increase_height(update: IncreaseHeight): + """ + Check that the new height is not less than the current planned height + (including all queued but not yet effective height increases), and that + the planned balance would still satisfy the minimum stake for the + requested height. + """ + if update.new_height < planned_height(msg.sender): + raise HeightDecreaseNotAllowed + if planned_balance(msg.sender) < minimum_stake(update.new_height): + raise BelowMinimumStake +``` + +The `BelowMinimumStake` check prevents a node from raising its height without the corresponding collateral. Without it, a node could end up with a recorded height that its balance does not support, making the stake record inconsistent with the minimum-stake-for-height invariant enforced elsewhere. The owner can top up first via `add_tokens` and then call `increase_height` once enough funds are queued (planned balance accounts for queued top-ups). + +###### 4.3.2.4. before_schedule_withdraw_tokens ```python def before_schedule_withdraw_tokens(update: WithdrawTokens): @@ -494,7 +547,7 @@ def before_schedule_withdraw_tokens(update: WithdrawTokens): raise ``` -###### 4.3.2.4. after_schedule_exit +###### 4.3.2.5. after_schedule_exit ```python def after_schedule_exit(update: Exit): @@ -506,7 +559,7 @@ def after_schedule_exit(update: Exit): ##### 4.3.3. Application logic -The semantic model of applying updates here is designed to parallel the current logic of the `manageStake` public interface. It cannot literally be implemented by calling `manageStake` since the semantics of the latter is changed to only *register* updates, not apply them. +The semantic model of applying updates here is designed to parallel the current logic of the staking operations interface. It cannot literally be implemented by calling the public staking methods since those only *register* updates, not apply them. ###### 4.3.3.1. apply (CreateDeposit, AddTokens, IncreaseHeight, ChangeOverlay) @@ -583,71 +636,72 @@ def apply_exit(owner: EthAddress, update: Exit): #### 4.4. Stake registry workflows -##### 4.4.1. manageStake +##### 4.4.1. Staking operations -Instead of writing directly to the stake record, each of the four remaining management workflows creates an `Update` object of the corresponding subtype, assigns a minimum wait, and places it on the queue via the `add_with_minimum_wait` pathway. +The `manageStake()` method is replaced by four separate public methods. Each creates an `Update` object of the corresponding subtype, assigns a minimum wait, and places it on the queue via `add_with_minimum_wait`. -Calls to `manageStake` do not set `frozenUntil` (formerly `lastUpdatedBlockNumber`). New records are initialised with `frozenUntil = 0`. Account initialization checks use the `overlay` field. +These methods do not set `frozenUntil` (formerly `lastUpdatedBlockNumber`). New records are initialised with `frozenUntil = 0`. Account initialization checks use the `overlay` field. ```python def is_initialized(owner: EthAddress) -> bool: return int.from_bytes(state.stakes[owner].overlay) != 0 - -def classify_update(owner: EthAddress, set_nonce: bytes, add_amount: int, height: int) -> list[Update]: - """ - Return subtype of update implied by parameters of call to manageStake. - - If updating an existing deposit, the call may combine multiple types. - Since the three types of updates touch different fields in the stake record, - the order of application does not matter. - """ - if is_initialized(owner): - # Mutate existing deposit - updates = [] - - # Assume height increase since this validity check is done at application time - updates.append(IncreaseHeight(height)) - - # add tokens? - if add_amount > 0: - updates.append(AddTokens(add_amount)) - - # change overlay? - new_overlay = derive_overlay(set_nonce) - if new_overlay != state.stakes[owner].overlay: - updates.append(ChangeOverlay(new_overlay)) - - return updates - else: - return [CreateDeposit(add_amount, set_nonce, height)] - MINIMUM_WAITS = { - CreateDeposit: WAIT_BASE, - AddTokens: WAIT_BASE, + CreateDeposit: WAIT_BASE, + AddTokens: WAIT_BASE, IncreaseHeight: WAIT_BASE, - ChangeOverlay: WAIT_BASE, - WithdrawTokens: WAIT_WITHDRAW, - Exit: WAIT_WITHDRAW + ChangeOverlay: WAIT_OVERLAY_CHANGE, + WithdrawTokens: WAIT_WITHDRAWAL, + Exit: WAIT_WITHDRAWAL, } -def manage_stake(set_nonce: bytes, add_amount: int, height: int): +def create_deposit(set_nonce: bytes, amount: int, height: int): """ - Classify updates into subtypes, check schedule-time validity, - transfer tokens to stake vault if necessary, - assign minimum waits, and add to schedule. + Create a new stake deposit. Reverts if a deposit already exists. + """ + if is_initialized(msg.sender): + raise AlreadyStaked + update = CreateDeposit(amount, set_nonce, height) + state.update_schedule[msg.sender] = ClosableSchedule() + before_create_deposit(update) + add_with_minimum_wait(state.update_schedule[msg.sender], update, MINIMUM_WAITS[CreateDeposit]) + +def add_tokens(amount: int): + """ + Add tokens to an existing deposit. + """ + if not is_initialized(msg.sender): + raise NotStaked + update = AddTokens(amount) + before_add_tokens(update) + add_with_minimum_wait(state.update_schedule[msg.sender], update, MINIMUM_WAITS[AddTokens]) + +def increase_height(new_height: int): """ - updates = classify_update(msg.sender, set_nonce, add_amount, height) - for update in updates: - match type(update): - case CreateDeposit: - # First item: must initialise queue object - state.update_schedule[msg.sender] = ClosableSchedule() - before_create_deposit(update) - case AddTokens: - before_add_tokens(update) - minimum_wait = MINIMUM_WAITS[type(update)] - state.update_schedule[msg.sender].add_with_minimum_wait(update, minimum_wait) + Increase height on an existing deposit. new_height is the new absolute height. + + Implementations MAY treat new_height == planned_height(msg.sender) as a no-op + (return without enqueueing). This avoids polluting the queue with entries + that would have no effect at apply time. + """ + if not is_initialized(msg.sender): + raise NotStaked + update = IncreaseHeight(new_height) + before_increase_height(update) + add_with_minimum_wait(state.update_schedule[msg.sender], update, MINIMUM_WAITS[IncreaseHeight]) + +def change_overlay(set_nonce: bytes): + """ + Change the overlay on an existing deposit. + + Implementations MAY treat derive_overlay(set_nonce) == planned_overlay as a + no-op (return without enqueueing). This avoids polluting the queue with + entries that would have no effect at apply time. + """ + if not is_initialized(msg.sender): + raise NotStaked + update = ChangeOverlay(set_nonce) + add_with_minimum_wait(state.update_schedule[msg.sender], update, MINIMUM_WAITS[ChangeOverlay]) ``` ##### 4.4.2. withdraw @@ -656,7 +710,7 @@ def manage_stake(set_nonce: bytes, add_amount: int, height: int): def withdraw(amount: int): update = WithdrawTokens(amount) before_schedule_withdraw_tokens(update) - add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAW) + add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAWAL) ``` ##### 4.4.3. exit @@ -664,7 +718,7 @@ def withdraw(amount: int): ```python def exit(): update = Exit() - add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAW) + add_with_minimum_wait(state.update_schedule[msg.sender], update, WAIT_WITHDRAWAL) after_schedule_exit(update) ``` @@ -683,23 +737,29 @@ def apply_updates(owner: EthAddress): ##### 4.4.5. Getter methods -With the exception of `addressNotFrozen` and `lastUpdatedBlockNumberOfAddress`, all view methods that read the stake record have changed semantics. +With the exception of `addressNotFrozen`, all view methods that read the stake record have changed semantics. -During the Claim phase of round R, an update scheduled to come into effect in round R+1 is visible via getters (so that `isParticipatingInUpcomingRound` reports correctly) but is not *poppable* (so tokens cannot be transferred out until round R+1, remaining exposed to freeze penalties for round R). +Getter methods return the effective state at the current round by virtually applying all ready updates via `peek_ready`. The `*Lookahead` variants accept a lookahead (non-negative number of rounds into the future) and use `peek_lookahead` to include updates effective at that target round. The `Redistribution` contract uses the `*Lookahead` variants to evaluate participation eligibility — see §4.6. ```python def stakes(owner: EthAddress) -> Stake: record = state.stakes[owner].clone() - for update in peek_upcoming(state.update_schedule[owner]): + for update in peek_ready(state.update_schedule[owner]): + record = apply_to(record, update) + return record + +def stakes_lookahead(owner: EthAddress, lookahead: int) -> Stake: + record = state.stakes[owner].clone() + for update in peek_lookahead(state.update_schedule[owner], lookahead): record = apply_to(record, update) return record ``` -Three other public API methods are defined by calling into the getter function above instead of reading the stakes table directly. +The following public API methods are defined by calling into the getter functions above: -* `nodeEffectiveStake` -* `overlayOfAddress` -* `heightOfAddress` +* `nodeEffectiveStake` / `nodeEffectiveStakeLookahead` +* `overlayOfAddress` / `overlayOfAddressLookahead` +* `heightOfAddress` / `heightOfAddressLookahead` ##### 4.4.6. migrateStake @@ -745,8 +805,6 @@ The implementer MAY make these functions available as `view` methods in the publ #### 4.6. Redistribution -Since the semantics of the `StakeRegistry` getter functions has changed, so too have the semantics of the three `Redistribution` functions that call them: `commit`, `reveal`, and `isParticipatingInUpcomingRound`. - Since the 2 round cool-off after a call to `manageStake` has been replaced with a delay managed by the update queue, the following check in the logic of `commit()` is no longer needed and should be removed: ```solidity @@ -757,17 +815,26 @@ Since the 2 round cool-off after a call to `manageStake` has been replaced with (see https://github.com/ethersphere/storage-incentives/blob/v0.9.4/src/Redistribution.sol#L303). -Since `nodeEffectiveStake` is zero for a frozen node, frozen nodes cannot participate even when this check is removed. Nonetheless, the implementer MAY wish to add a freeze status check to `commit()` so that participation fails early for a frozen node, saving on computation. +`commit()` reads the current effective stake via `nodeEffectiveStake` (which returns 0 for frozen nodes and for nodes whose deposit has not yet become effective). -#### 4.7. Height decrease +`isParticipatingInUpcomingRound` uses the `*Lookahead` getter variants to evaluate eligibility. During the commit phase, the lookahead is 0 (current round); during the claim phase, it is 1 (next round). This allows the method to correctly report eligibility for a round whose effective state may differ from the current round's. + +```python +def is_participating_in_upcoming_round(owner: EthAddress, depth: int) -> bool: + if current_phase_reveal(): + raise WrongPhase + lookahead = 1 if current_phase_claim() else 0 + stake = node_effective_stake_lookahead(owner, lookahead) + if stake == 0: + raise NotStaked + height = height_of_address_lookahead(owner, lookahead) + overlay = overlay_of_address_lookahead(owner, lookahead) + return in_proximity(overlay, current_round_anchor(), depth - height) +``` -Height decreases on an active stake position are disallowed. +#### 4.7. Height decrease -> **ALIGNMENT POINT.** Height decreases can be blocked in a few different ways: -> -> 1. Block at scheduling time. This requires lookahead on all pending updates to see if the new height would be valid at application time, and increases the complexity of the queue. -> 2. Fail silently at application time. When applying an update that would decrease the height, simply do not honour the height change. From the perspective of the current interface, this is the lowest profile approach. However, it violates the principle that validity is checked at scheduling time and that scheduled updates will always be applied, which could lead to subtle errors in client implementations. -> 3. Block at interface level. Make the public API for changing height on an existing deposit take an unsigned integer *height increase* instead of the new height. Probably the cleanest solution but requires another public API change. +Height decreases on an active stake position are disallowed. The `increaseHeight` method takes the new absolute height and reverts via the `before_increase_height` scheduling hook (§4.3.2.3) if the new height is less than the current planned height (including any queued but not yet effective height increases). ## 5. Rationale @@ -836,11 +903,13 @@ Height decreases on an active stake position are disallowed. * *Reward sharing.* For the advance signalling function of an exit queue to work, nodes must be incentivised to continue operating while they are in the queue. Hence, they must be able to continue participating in reward sharing (and penalties) using their previous participation metadata while waiting. Accordingly, they must participate in all the activities that qualify them for reward sharing, i.e. reserve consensus and storage and density proofs. -* *Freezing.* Under the current system, frozen nodes are not allowed to mutate stake records. The effect of this is that if a frozen node decides they wish to update their stake record, they must wait until the freeze ends, execute the update, and wait another 2 rounds to participate again. In other words, as well as blocking participation, freeze penalties delay executing changes to the stake record. This behaviour appears to be undocumented (cf. https://docs.ethswarm.org/docs/concepts/incentives/redistribution-game#penalties), and it's not clear if it's important. +* *Freezing.* Under the current system, frozen nodes are not allowed to mutate stake records. Since updating stake records is now split into two separate operations, this decision must be revisited. The current proposal states that scheduling updates (deposits, top-ups, height increases, overlay changes, withdrawals, and exits) is permitted while frozen. The rationale here is that the intended effects of freezing and update minimum wait periods apply even if these periods overlap, so preventing them from overlapping would constitute an unpredictable, unnecessary additional penalty. - This SWIP does not propose to change this behaviour, but we note that its effects are exacerbated by introducing longer record update delays. A node operator who decides while frozen to update their stake record must wait the update delay sequentially after the freeze period. On the other hand, if an update is enqueued and *then* the node gets frozen, the freeze period and the update delay run concurrently. + The application of withdrawals and exits is blocked while the node is frozen, since allowing token transfers during a freeze would let operators evade penalties. - Applying effective updates in state is purely a gas management measure, and does not affect any values that can be read from the contract. + The question of whether other updates to the stake record (deposits, top-ups, height increases, overlay changes) can be applied while frozen has no effect on the user experience, since these changes are only meaningful when participating and participation is blocked while frozen. Our semantic model states that they are allowed, but this is up to the implementer. + + `freezeDeposit` MAY opportunistically drain all ready non-withdrawal updates from the owner's queue as part of its execution. This is a benign implementation choice: ready updates would be applied anyway on the next `applyUpdates` call, and performing them inside `freezeDeposit` keeps the stake record in a consistent state at the moment the freeze takes effect. (Queued withdrawals and exits are still deferred by the freeze check in their application logic; see §4.3.3.) * *Pausing.* See §4.4.6.