From 6346b7f9cfb9c33cfe3103e0a2dd54cd6b70b107 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 7 Sep 2026 11:46:45 +0200 Subject: [PATCH 01/24] add SWIP-67: custody separation and fork migration Splits each fund-holding storage-incentive contract into a frozen custody core and a replaceable policy contract, and specifies the fork-migration protocol that policy replacement runs under. Consolidates the security thread from storage-incentives#310 and the migration thread from Andrew Macpherson's "Forking Swarm" into one proposal, on the basis that both stem from state and logic sharing a contract. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-67.md | 739 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 739 insertions(+) create mode 100644 SWIPs/swip-67.md diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md new file mode 100644 index 00000000..b7165390 --- /dev/null +++ b/SWIPs/swip-67.md @@ -0,0 +1,739 @@ +--- +SWIP: 67 +title: Custody separation and fork migration +author: Cardinal (@0xCardiE), Andrew Macpherson (@awmacpherson) +discussions-to: https://github.com/ethersphere/SWIPs/pull/108 +status: Draft +type: Standards Track +category: Core +created: 2026-09-07 +--- + + + +## Simple Summary + +Swarm's storage-incentive contracts keep user money and changeable rules in the same +place. That single fact causes both of our recurring problems: + +- **Security.** To change the rules without asking every user to move their money, we + gave admins powers over money. The redistributor role can send the entire postage pot + to any address; the admin role can mint batch state that no one paid for. +- **Migrations.** When we refuse to use those powers, we must instead move everyone's + money — and a stake migration alone has cost the network ten days and half its target + replication. + +This SWIP splits each contract in two. A **core** holds the money, has no admin, is never +upgraded, and enforces its own accounting invariants. A **policy** holds the rules, is +freely replaceable, and can never name a payment destination. It then specifies the +**fork-migration protocol** — how a new policy and a new Redistribution contract are cut +over atomically at a round boundary, so that a protocol upgrade stops being a fund +movement at all. + +## Abstract + +We specify two coupled changes to the storage-incentive contract suite. + +**Part 1 — Custody separation.** `PostageStamp` and `StakeRegistry` are each split into a +frozen custody core (`PostageAccounting`, `StakingCore`) and a replaceable policy contract +(`PostagePolicy`, `StakingPolicy`). Cores hold all BZZ, expose no function that transfers +to a caller-supplied address, enforce token-conservation invariants against their own +recorded state, rate-limit every value-moving primitive a policy can trigger, change their +policy pointer only through a self-enforced timelock, never call into policy, and offer a +permissionless exit that no role can pause. Policies hold batch admissibility, pricing, +overlay derivation, commitment and effective-stake maths, and slashing rules. + +**Part 2 — Fork migration.** Every breaking wire-protocol release MUST be accompanied by a +new `Redistribution` deployment, even when its code is unchanged, so that the two branches +of the resulting network fork do not play the same redistribution game. Cutover is +signalled on chain by a `Cutover` contract that publishes *timing only*; contract addresses +are carried in the client binary. Cutover MUST land on a round boundary, with the outgoing +redistributor refusing new commits one round early so the game drains rather than stops. +`PostageAccounting` enforces at most one authorised redistributor at any block. + +Together the parts remove admin custody of deposits, bound admin influence over future +rewards, and reduce a protocol upgrade from "everyone moves their money" to "clients point +at a new policy address". + +## Motivation + +### The two problems are one problem + +Two threads have been running in parallel: a security thread about admin powers and +upgradeability (see [`storage-incentives#310`][pr310]), and a migration thread about how +we roll out new network versions (see *Forking Swarm*). They are the same problem +seen from two sides. + +Because state and logic live in the same contract, replacing logic means replacing state. +Replacing state means a migration. Avoiding the migration means giving an admin a shortcut +over state — which is a power over funds. So we oscillate between two bad options: + +1. **Use the admin shortcut.** Cheap, but the admin can steal the pot and burn all stake. +2. **Do a full redeployment and migrate everything.** Rug-resistant, but it has cost the + network real downtime and real money, and we have never once managed a batch migration + without admin-driven cloning. + +The conclusion drawn in *Forking Swarm* — that phasing out admin powers makes +surgical redeployment impossible, so every upgrade must become a full-suite redeployment +with batch and stake migration — is true of the *current* architecture but is not +architecturally necessary. It is a consequence of the coupling, not of the threat model. +Break the coupling and both options improve at once. + +### Where the custody surface actually is, in code + +The following are properties of the deployed contracts as of writing, not hypotheticals. + +**`PostageStamp.withdraw(address beneficiary)`** is gated on `REDISTRIBUTOR_ROLE` and +transfers the whole of `totalPot()` to a caller-supplied address. One call, entire pot, any +destination. + +**`REDISTRIBUTOR_ROLE` is an OpenZeppelin `AccessControl` role**, so any number of addresses +can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. This is not a theoretical +concern: during the v0.9.3/v0.9.4 rollout two live redistributors were authorised on the +same `PostageStamp` at once, and the resulting race bled roughly 15 BZZ from operators on +the production branch over three weeks (*Forking Swarm*, case study 2). + +**`PostageStamp.copyBatch` and `copyBatchBulk`** are gated on `DEFAULT_ADMIN_ROLE` and +create batch state — owner, depth, `normalisedBalance` — while incrementing +`validChunkCount`, **without transferring any BZZ into the contract**. `totalPot()` returns +`min(pot, balance)`, so this cannot directly over-transfer; but unbacked chunks accrue pot +at the same rate as paid ones, so the admin can accelerate pot accrual against the deposits +of real batch owners. Any honest accounting of admin attack surface must include these +functions alongside redistributor assignment. They exist to facilitate exactly the batch +migrations this SWIP aims to make unnecessary. + +**`StakeRegistry` is, by contrast, genuinely rug-resistant today.** No code path sends BZZ +anywhere except back to `msg.sender` (`withdrawFromStake`, `migrateStake`), and +`slashDeposit` only decrements the record without transferring, so slashed BZZ is burnt in +place rather than stolen. This property is worth stating precisely because it is the +property any change must preserve: making `StakeRegistry` upgradeable in the ordinary sense +would be a strict increase in attack surface, from "burn" to "steal". + +**The existing escape hatch does not survive its own threat model.** +`StakeRegistry.migrateStake()` is `whenPaused`, and `pause()` requires `PAUSER_ROLE`. In +the scenario the hatch exists for — the admin is the adversary — the hatch is closed by the +adversary. An escape hatch gated on a privileged role is not an escape hatch. + +### What migrations have cost + +From *Forking Swarm*: + +- **Wire-only fork, v2.8.0 (2026-05-26).** A breaking wire-protocol change shipped without + a new `Redistribution`. Rounds with a dissenting reveal went from approximately zero per + week to approximately twenty; 2.8% of rounds in the first week; 44 distinct dissenting + identities; nine rounds in three weeks (0.38%) in which a dissenter was leader. In round + 306865 a dissenter revealed depth 10, so every node was frozen for twice as long and the + depth floor blocked all nodes from the following round. +- **Staggered surgical redeployment, v0.9.3/v0.9.4 (2025-06 to 2025-08).** Two live + redistributors for three weeks (~15 BZZ bled). Then a six-day window in which operators + who *had* upgraded could not earn, because stake migration cannot begin until the old + registry is paused. Roughly half of nodes migrated within three days of unpausing and + most of the rest a week later. For a week the publicly advertised branch of Swarm was + effectively a Foundation-operated cloud service; for the following week it ran at half + target replication. + +The sharpest framing in that document is that **any time between a new client release and +the pausing of the old stake registry is network downtime**. Migration is not an +inconvenience to be scheduled; it is an outage to be designed away. + +### Why not simply put everything behind proxies + +[`storage-incentives#310`][pr310] proposes upgradeable proxies for all core contracts plus +an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path that +lets a client pin an expected implementation atomically. The reviewer objections to that +approach are, in our assessment, correct, and this SWIP is the alternative: + +- A proxy over a fund-holding contract hands the proxy admin the ability to steal those + funds. For `StakeRegistry` this converts today's "admin can burn stake" into "admin can + steal stake". +- Verifying the registry inside the proxy fallback taxes every user call and introduces a + liveness hazard: a mistaken deprecation or a codehash mismatch reverts *all* user calls, + including withdrawals. That layers an availability risk on top of the custody risk it is + trying to mitigate. +- `pinnedExecute` imposes a permanent selector-collision constraint on every future + implementation ABI and adds a second delegatecall path parallel to the fallback. +- Most importantly, the machinery solves "the admin swapped the implementation under me". + If user funds live in a contract that cannot be swapped, that event is no longer a + fund-loss event, and the machinery is not needed. + +The on-chain registry does have real value, but it is coordination and observability +value, not security value: the trust root for which contracts a node talks to is the client +release process either way. This SWIP therefore keeps a registry-like contract and gives it +the job it is actually good at — signalling cutover timing (Part 2, F2) — and drops the +guarded proxy and `pinnedExecute`. + +### What this SWIP does not claim + +Custody separation removes the *on-chain* cost of a migration. It does not remove the fork +itself. Per-batch bucket counters, stamp validity as seen by nodes, and chunk availability +are off-chain, per-branch state, and they still partition on a wire-protocol change exactly +as described in *Forking Swarm*. Batches carry across a fork unchanged under this +proposal; the stamp set still forks. What disappears is the coordination tax that made +forks expensive enough to avoid. + +## Specification + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in +RFC 2119. + +### Part 1 — Custody separation + +#### C1. Structure + +Each fund-holding contract is split into two deployed contracts. + +| Core (frozen, no admin, holds BZZ) | Policy (replaceable, holds no BZZ) | +|---|---| +| `PostageAccounting` — batch ownership, per-batch balance, pot, total deposited, total paid out | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price ingestion, expiry ordering | +| `StakingCore` — per-address deposit, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | + +`Redistribution` and `PriceOracle` are policy-class contracts: they hold no user funds and +are plain redeployments, never proxies (see Part 2). + +Cores MUST NOT be deployed behind a proxy. Cores MUST NOT contain `delegatecall`. Policies +MAY be deployed behind a proxy or MAY be plain redeployments; this SWIP does not mandate +either, because C2 makes the choice non-custodial. Given F1, plain redeployment is expected +to be simpler in practice. + +#### C2. Core invariants + +These are the substance of the proposal. A split that does not satisfy them buys nothing: +it relocates the trust boundary by one hop and leaves it exactly as wide. Note that the +current architecture already has the shape "frozen ledger, swappable policy" — a +`PostageStamp` that never changes, with a replaceable `Redistribution` authorised on it — +and it leaks full custody, because `withdraw(beneficiary)` is an unconstrained primitive. +The shape is not the property. The invariants are. + +**C2.1 — No caller-supplied destinations.** No function on a core MAY transfer tokens to an +address supplied by the caller or by policy. Every destination MUST be derived from the +core's own recorded state: + +- `PostageAccounting.refundBatch(batchId)` pays `batches[batchId].owner`. +- `StakingCore.withdraw()` pays `msg.sender`. +- `PostageAccounting.claimPot()` pays the single authorised redistributor address, which is + itself set only via C2.5. + +**C2.2 — Conservation, enforced by the core.** Each core MUST track total deposited and +total paid out, and MUST maintain, checked at the end of every state-changing call: + +``` +sum(recorded claims) + pot <= token.balanceOf(core) +``` + +The core MUST own exactly enough arithmetic to police this and no more. In particular, pot +growth MUST be bounded by the core independently of policy's accounting: policy may *assert* +an accrual, but the core MUST reject any accrual that would breach the inequality above. +This matters because unbacked-batch creation (`copyBatch`) is precisely a breach of it, and +under C2.2 no policy — honest, buggy, or malicious — can reproduce that behaviour. + +**C2.3 — One-way calls.** Calls MUST go policy → core only. A core MUST NOT call, delegate +to, or read from its policy, and MUST NOT expose callbacks or hooks. Core correctness MUST +NOT depend on policy code. A corollary: a core cannot ask policy whether an action is +permitted; every check a core performs is self-contained. + +**C2.4 — Bounded authority.** Every value-moving primitive a policy can trigger MUST be +rate-limited by the core: + +| Primitive | Bound | +|---|---| +| `claimPot()` | at most `MAX_POT_FRACTION_PER_ROUND` of `pot` per `ROUND_LENGTH` window | +| `slash(node, amount)` | at most `MAX_SLASH_PER_EPOCH` per node and in aggregate per epoch | +| pot accrual | bounded by C2.2, and by `MAX_PRICE` on the ingested price | + +Suggested initial values are given in [Open questions](#open-questions); they are +parameters of the deployment, immutable in the core once set. The purpose of the bounds is +not to make theft impossible in the limit — it is to make it *slow and visible*, so that the +exit in C2.6 has a usable window. + +**C2.5 — Timelocked policy pointer, enforced by the core.** A core MAY allow its policy +pointer to change, and if it does: + +- the change MUST be proposed and then executed no earlier than `POLICY_TIMELOCK` blocks + later, with both proposal and execution emitting events; +- the timelock MUST be enforced by the core itself, not by an external timelock contract + that a role could replace; +- `POLICY_TIMELOCK` MUST be immutable. + +We state plainly what this is and is not. A core with a timelocked policy pointer **has a +privileged operation**; it is not literally admin-free. The claim being made is narrower and +checkable: *no privileged operation can move a user's deposit, and every privileged +operation is announced in advance with a guaranteed exit window*. Proposals that describe +this as "no admins" should be corrected to this formulation. + +**C2.6 — Permissionless exit.** Each core MUST provide an exit that: + +- any principal can call for their own funds, with no role check; +- has no pause modifier and cannot be disabled by any role; +- does not route through any replaceable contract; +- ignores policy-supplied state (commitments, freezes, height) when computing the exit + amount, using only core-recorded claims. + +Concretely: `StakingCore.exit()` returns the caller's recorded deposit, and +`PostageAccounting.refundBatch(batchId)` returns the batch's remaining balance to its owner. +`StakingCore.exit()` SHOULD be subject to an `EXIT_DELAY` (a fixed unbonding period, not a +role-gated pause) so that it cannot be used to dodge in-flight slashing. + +The postage exit needs an economic guard, because a batch owner could otherwise top up, +upload, and immediately refund, obtaining storage for free. `refundBatch` SHOULD forfeit a +fixed fraction of the remaining balance to the pot, or be subject to a minimum batch age. +This is an economic parameter, not a security one, and is listed as an open question. + +**C2.7 — Frozen means frozen.** Cores have no upgrade path. This is the risk the proposal +takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin is +worse than an upgradeable contract. Therefore: + +- Cores hold balances, ownership, monotone accumulators, and the conservation check. Nothing + else. +- Everything with interesting edge cases — the expiry ordering structure, batch selection, + depth and bucket rules, effective-stake curves, commitment maths — lives in policy, where + it can be fixed. +- Cores MUST be formally specified and MUST have full invariant and fuzz coverage before + deployment (see [Test cases](#test-cases)). + +#### C3. `StakingCore` interface + +Staking is the easier of the two cases and SHOULD be done first: its only funds-out +direction is already "pay `msg.sender`", so C2.1 is satisfiable without changing any user's +economics, and today's rug-resistance is preserved exactly rather than approximated. + +```solidity +interface IStakingCore { + // ---- user ---- + /// @notice Deposit BZZ. Credited to msg.sender. No policy call. + function deposit(uint256 amount) external; + + /// @notice Withdraw up to `amount` of the caller's unlocked deposit. Pays msg.sender only. + function withdraw(uint256 amount) external; + + /// @notice Permissionless exit (C2.6). Not pausable, ignores policy state. + /// Callable EXIT_DELAY blocks after requestExit(). + function requestExit() external; + function exit() external; + + // ---- policy, bounded (C2.4) ---- + /// @notice Reduce a deposit. Burnt in place; never transferred out. + /// Reverts if per-node or per-epoch slash caps are exceeded. + function slash(address node, uint256 amount) external; + + /// @notice Prevent withdraw() (but never exit()) for `until`. + function lock(address node, uint64 until) external; + + // ---- views ---- + function depositOf(address node) external view returns (uint256); + function totalDeposited() external view returns (uint256); +} +``` + +`StakingCore` MUST NOT store overlays, heights, committed stake, or effective stake, and +MUST NOT read `PriceOracle`. Those are per-branch, consensus-critical values, and belong in +`StakingPolicy` for a reason that matters at fork time: **overlay derivation is bound to the +wire protocol** (it mixes `NetworkId`), so it is exactly the kind of value that should be +redeployed with a fork, while deposits are exactly the kind that should not. + +`StakingPolicy` SHOULD accept an immutable `predecessor` address and lazily inherit overlay +and height from it on first use, so that a fork requires no operator transaction at all. +Note that `Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before +participation; inheriting predecessor state avoids re-triggering that delay, whereas a +fresh declaration would cost operators roughly two rounds (~25 minutes at +`ROUND_LENGTH = 152` on Gnosis) rather than the ten days observed in 2025. + +#### C4. `PostageAccounting` interface + +```solidity +interface IPostageAccounting { + // ---- user ---- + /// @notice Fund a batch id. Amount is transferred in; credited to `owner`. + function fund(bytes32 batchId, address owner, uint256 amount) external; + + /// @notice Add funds to an existing batch. Owner unchanged. + function topUp(bytes32 batchId, uint256 amount) external; + + /// @notice Permissionless exit (C2.6). Pays batches[batchId].owner only. + /// May forfeit a fixed fraction to the pot (see C2.6). + function refundBatch(bytes32 batchId) external; + + // ---- policy, bounded (C2.4) ---- + /// @notice Debit a batch and credit the pot. Reverts if the conservation + /// invariant (C2.2) or MAX_PRICE would be breached. + function accrue(bytes32 batchId, uint256 amount) external; + + /// @notice Pay out to the single authorised redistributor. Capped per round (C2.4). + /// Destination is not a parameter. + function claimPot(uint256 amount) external; + + // ---- redistributor pointer (C2.5, F4) ---- + function proposeRedistributor(address next) external; + function executeRedistributor() external; + + // ---- views ---- + function balanceOf(bytes32 batchId) external view returns (uint256); + function ownerOf(bytes32 batchId) external view returns (address); + function pot() external view returns (uint256); + function redistributor() external view returns (address); +} +``` + +`claimPot` takes an amount but not a destination. There is no `withdraw(address)`. The +redistributor pointer is singleton by construction rather than by role hygiene, which is +the direct fix for the v0.9.3 double-redistributor race. + +Batch *identity and semantics* — bucket depth validity, immutability flags, minimum initial +balance, depth-increase rules — live in `PostagePolicy`. `PostageAccounting` records only +that a batch id is owned by an address and holds a balance. The expiry ordering structure +(today `HitchensOrderStatisticsTreeLib`) lives in policy; the core does not need it, because +under C2.2 it bounds pot growth by conservation rather than by recomputing expiry. + +#### C5. Residual trust after Part 1 + +Stated explicitly so it can be argued with: + +| Capability | Today | After Part 1 | +|---|---|---| +| Steal all staked BZZ | No (burn only) | No | +| Burn all staked BZZ | Yes (redistributor role) | No — capped per epoch (C2.4) | +| Steal the entire pot in one call | Yes (`withdraw(beneficiary)`) | No — no such primitive (C2.1) | +| Drain the pot over time | Yes | Bounded, visible, timelocked (C2.4, C2.5) | +| Create unbacked batch state | Yes (`copyBatch`) | No (C2.2) | +| Misdirect *future* rewards | Yes | Yes, after `POLICY_TIMELOCK`, announced | +| Close the user escape hatch | Yes (`PAUSER_ROLE`) | No (C2.6) | + +The row that does not go away is the last-but-one: whoever controls policy can still bias +who wins the pot, which is an indirect claim on future revenue. **Custody separation protects +deposits, not rewards.** Bounding reward direction further would require freezing +redistribution verification itself, which conflicts directly with Part 2's requirement that +`Redistribution` be redeployed per fork. We consider the trade correct and name it rather +than paper over it. + +### Part 2 — Fork migration + +#### F0. Definitions + +A **fork** of the Swarm network is a second network whose initial state is a clone of a +subset of the first's — canonically, of the stamp set. A **fork-migration** is a fork in +which the old branch is intended to be wound down. Every breaking change to the Swarm wire +protocol to date has been a fork-migration (*Forking Swarm*). + +A **breaking wire release** is a client release whose peer-negotiated protocol version +differs from its predecessor's, such that a version mismatch causes disconnection. Because +mismatch causes disconnection, a breaking wire release always produces at least two disjoint +p2p networks. + +#### F1. A new `Redistribution` per breaking wire release + +Every breaking wire release MUST be accompanied by the deployment of a new `Redistribution` +contract, **even if its bytecode is unchanged**. + +Rationale: contract identity, not the wire version, is what partitions the incentive game. +Without a new `Redistribution`, both branches play the same game with divergent views of the +stamp set — a negative-sum outcome in which stragglers claim a share of payments intended +for the new branch, upgraded nodes earn less, and honest nodes can be frozen for +disagreeing with a non-upgraded leader. This is the measured v2.8.0 failure mode. + +`Redistribution` holds no state worth preserving, so this is close to free. It is the +cheapest recommendation in this SWIP and SHOULD be adopted as standing practice +independently of everything else here. + +#### F2. Cutover signalling: timing on chain, addresses in the binary + +A `Cutover` contract publishes the schedule: + +```solidity +interface ICutover { + struct Schedule { + uint32 wireVersion; // client protocol version this cutover activates + uint64 activationBlock; // MUST be a multiple of ROUND_LENGTH (F3) + bytes32 manifest; // hash of the release's address set + } + + function schedule(uint32 wireVersion) external view returns (Schedule memory); + function current() external view returns (Schedule memory); + + event CutoverScheduled(uint32 wireVersion, uint64 activationBlock, bytes32 manifest); + event CutoverExecuted(uint32 wireVersion, uint64 atBlock); +} +``` + +Two normative rules govern its use. + +**F2.1 — The signal carries timing; the binary carries addresses.** A client MUST NOT learn +a contract address from the chain and act on it. Contract addresses MUST be compiled into +the client release. The `Cutover` contract may tell a client *when* to switch; it MUST NOT +be able to tell it *where*. The `manifest` field is a hash the client checks against its +own compiled address set, and a mismatch MUST be a hard failure, not a warning. + +This rule exists because the alternative is an automated fund-redirection trigger. A client +that reads a destination address from chain and then moves the operator's stake to it has +reproduced, inside the client, exactly the admin power this SWIP removes from the contracts. + +**F2.2 — Schedules are event-driven, not height-hardcoded.** Clients MUST determine +activation by observing `Cutover` state, not by a height baked into the binary. A hardcoded +height fixes the date at release-engineering time; if the date must slip — a bug is found, +the multisig cannot assemble, the chain has an incident — every client in the field holds +the wrong height and an emergency release is required. A rescheduled `activationBlock` MUST +be re-announced at least `CUTOVER_NOTICE` blocks before the new activation. + +#### F3. Round-aligned atomic cutover + +`activationBlock` MUST satisfy `activationBlock % ROUND_LENGTH == 0`. + +Cutover is not instantaneous with respect to the redistribution game. A round spans +`ROUND_LENGTH` blocks (152 at present) and is divided into commit, reveal and claim phases. +A cutover landing mid-round orphans nodes that have already committed: they lose their +reveal window and may be frozen for a phase violation they did not cause. + +Therefore: + +- The outgoing `Redistribution` MUST stop accepting new commits from the start of the round + preceding `activationBlock`, so the final round drains through reveal and claim normally. +- The incoming `Redistribution` MUST accept commits from `activationBlock` onward. +- The authority change on `PostageAccounting` (F4) MUST execute at `activationBlock`. + +"No gap" and "no orphaned round" are distinct properties. This SWIP requires both. + +#### F4. Exactly one redistributor, by construction + +`PostageAccounting` MUST authorise at most one redistributor address at any block. The +pointer changes only through `proposeRedistributor` / `executeRedistributor` under +`POLICY_TIMELOCK` (C2.5), and `claimPot` reverts for any caller that is not the current +pointer. + +This replaces `REDISTRIBUTOR_ROLE` as an `AccessControl` role, under which multiple holders +are representable and were in fact simultaneously authorised in 2025. Singleton-ness becomes +a property of the type, not of operational discipline. + +Cutover execution is therefore: `executeRedistributor()` on `PostageAccounting`, plus the +policy pointer update if policy changed, in a single transaction from the governing +multisig. It MUST be a single transaction. "Atomic" is not satisfied by several transactions +sent close together — the v0.9.3 incident is what several transactions close together looks +like. + +#### F5. Old-branch wind-down + +Immediately zeroing rewards on the old branch is correct for incentive alignment and wrong +for data availability: old-branch data remains retrievable only while old-branch nodes stay +online, which is precisely when they have stopped being paid. + +Where a fork requires user-side action with a tail — a wire-protocol change, since batches +themselves now carry across — the schedule SHOULD include a wind-down window during which +the old `Redistribution` continues to pay at a reduced rate, decaying to zero. This is a +deliberate exception to "no overlap", and it is safe under F4 in a way it was not in 2025: +the two redistributors are authorised against *different* postage cores only if a postage +migration is happening at all, and in the normal case there is one core, one pointer, and +the wind-down is paid from a fixed, pre-funded allocation rather than from the live pot. + +The residual pot in any retired core MUST have a defined destination. This SWIP does not +fix one; see [Open questions](#open-questions). + +#### F6. Client requirements + +A conforming client: + +1. MUST compile in the full address set for each protocol version it supports, and the + `manifest` hash for each. +2. MUST read `Cutover` for timing only, and MUST hard-fail on `manifest` mismatch (F2.1). +3. MUST switch the `Redistribution` address it uses at `activationBlock`, not when the + operator restarts. +4. MUST NOT send any fund-moving transaction as an automated consequence of a chain signal. + Under Part 1 no such transaction is required at cutover, which is the point. +5. SHOULD expose the pending cutover in its status API and log a warning when it is running a + version whose cutover has passed. + +#### F7. Relationship between the parts + +Part 2 alone still requires stake migration at every fork, which is the ten-day outage. Part +1 alone leaves the fork boundary undefined, so wire-only forks keep commingling incentives. +Together: + +- Deposits never move, so cutover involves no user or operator fund transaction (F6.4). +- `Redistribution` identity still changes per fork, so branches never share a game (F1). +- Batches carry across, so there is no batch migration and `copyBatch` can be retired. +- The window "between release and pausing the old registry" — the observed downtime — + collapses to zero. + +## Rationale + +**Why not proxies over the fund-holding contracts.** Covered in [Motivation](#motivation). +Briefly: a proxy over a vault is a custody grant; per-call registry verification is an +availability risk; and if the vault cannot be swapped, the anti-swap machinery is +unnecessary. + +**Why not "always full redeploy".** *Forking Swarm*'s proposal is coherent but +expensive, and its cost is not bounded in the document. It requires a batch migration at +every breaking wire release, and it leaves batch migration undesigned. It also relies on an +incentive asymmetry that does not hold: operators follow money and will migrate stake to +keep earning, but a user who fails to migrate a batch loses availability they may not notice +until they need the data. Operators follow money; users follow nothing. Part 1 removes the +requirement rather than solving the coordination problem. + +**Why the registry survives as a cutover signal.** The reviewer question on +[`storage-incentives#310`][pr310] — who benefits from an on-chain registry, and how does it +compare to publishing under ENS or on GitHub — has a straight answer: for *security* it adds +nothing, because the trust root is the client release process either way. For *coordination* +it adds something real, because it lets every client switch at the same block regardless of +when its operator restarted. F2 keeps the coordination and F2.1 removes the security +temptation. + +**Why staking first.** It is the case where the target property is cleanest (funds already +only flow to `msg.sender`), it is the case where the objection to upgradeability was +strongest, and demonstrating a frozen core there earns the standing to freeze the postage +ledger afterwards. It is also the case that removes the observed downtime. + +**Why bounds rather than prohibitions.** A design in which policy has no authority at all +over funds cannot slash, cannot pay winners, and is therefore not an incentive system. The +achievable goal is not zero authority but *bounded, announced, visible* authority with a +usable exit. C2.4 through C2.6 are that goal made concrete. + +**Alternatives considered and rejected.** + +- *Immutable policy pointer in the core.* Strictly stronger, but then changing policy means + a new core, which reintroduces migration and defeats the purpose. +- *External timelock contract owning the pointer.* Weaker than C2.5, because whoever can + replace the timelock's owner can shorten the window. Self-enforcement in the core with an + immutable constant is the point. +- *Governance vote on policy changes.* Orthogonal and compatible; this SWIP specifies the + contract-level constraints that hold regardless of how the governing address is + constituted. +- *Keeping the expiry tree in the core.* Rejected under C2.7: it is the most edge-case-heavy + component and the one most likely to need a fix. + +## Backwards compatibility + +This is a breaking change to the contract suite and requires a coordinated release. It is +also, by design, intended to be the **last** such change that moves user funds. + +**Two migrations, once.** + +1. *Final stake migration.* Operators move deposits from `StakeRegistry` to `StakingCore`. + This is the last time. It SHOULD be run under the F2/F3 protocol, and — unlike 2025 — the + old registry MUST be paused at `activationBlock` rather than at an unrelated later date, + so that no window exists in which an upgraded operator cannot earn. +2. *Final batch migration.* Batches move from `PostageStamp` to `PostageAccounting`. This is + the last time. It is the harder of the two and SHOULD be user-driven wherever possible; if + an admin-assisted path is used for the tail, that path MUST be time-limited by an + immutable deadline in `PostageAccounting` after which it cannot be called, and MUST require + a matching BZZ transfer so that C2.2 holds during migration. That last requirement is the + specific defect in today's `copyBatch`. + +**Retirement of `copyBatch`.** `PostageAccounting` MUST NOT include an unbacked +batch-creation function. After migration, `copyBatch` and `copyBatchBulk` cease to exist as +a capability. + +**Client ABI.** Clients must learn a two-contract layout per subsystem: reads that are +consensus-critical (overlay, effective stake, batch validity) come from policy; balances and +deposits come from the core. Clients SHOULD read policy for anything that can change per +fork and core for anything that must not. + +**Integrators.** Anything reading `PostageStamp.batches(...)` or `StakeRegistry.stakes(...)` +directly must be updated. A compatibility view contract MAY be deployed to preserve the +current read ABI; it MUST be read-only and MUST NOT be depended on by clients for +consensus-critical values. + +## Test cases + +Cores are unupgradeable, so their test burden is qualitatively different from ordinary +contract tests. The following are mandatory before any core deployment. + +**Invariant tests (must hold after every call, under all orderings).** + +- `sum(recorded claims) + pot <= token.balanceOf(core)` (C2.2). +- `totalDeposited - totalWithdrawn - totalSlashed == token.balanceOf(StakingCore)`. +- No execution path transfers to an address not derived from core state (C2.1) — enforced by + a static check over the core's bytecode as well as by tests. +- No core function reaches an external call into the policy address (C2.3). + +**Adversarial-policy tests.** Instantiate each core with a deliberately malicious policy +that attempts, at minimum: draining the pot in one call; slashing every node to zero; +claiming more than the per-round cap; accruing pot beyond conservation; blocking a user's +exit; setting a price above `MAX_PRICE`. Each MUST revert, and `exit()` MUST succeed +throughout. + +**Exit tests.** `exit()` and `refundBatch()` MUST succeed while the policy is malicious, +while the policy address is zero, while a policy change is pending in the timelock, and — for +`StakingCore` — while the node is locked or frozen by policy. + +**Timelock tests.** A policy or redistributor change MUST NOT take effect before +`POLICY_TIMELOCK`; the pending change MUST be readable throughout the window. + +**Cutover tests.** A cutover at a round boundary MUST NOT orphan a committed node (F3); a +cutover proposed off-boundary MUST revert; the old redistributor MUST reject commits in the +final round and MUST still accept reveals and claims for the round already committed; a +`manifest` mismatch MUST cause client hard-failure. + +**Fuzz and differential.** Fuzz the conservation invariant across randomised sequences of +deposit, top-up, accrue, claim, slash, withdraw and exit. Differentially test +`PostagePolicy` accrual against the current `PostageStamp` expiry logic over historical +batch data, to confirm the split preserves today's accounting. + +## Implementation + +Staged so that each stage is independently valuable and independently revertible. + +| Stage | Content | Depends on | +|---|---|---| +| 1 | Surgical `Redistribution` redeployment with security fixes; round-aligned atomic cutover; single redistributor; stake and batches untouched | — | +| 2 | F1 adopted as standing practice: new `Redistribution` on every breaking wire release | — | +| 3 | `Cutover` contract and client support (F2, F3, F6). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | +| 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | +| 5 | `PostageAccounting` + `PostagePolicy`. Final batch migration. `copyBatch` retired | 4 | +| 6 | `POLICY_TIMELOCK` extended; governing multisig scope reduced to policy pointers only | 5 | + +Stage 1 addresses measured harm and is the immediate next upgrade. Stage 2 is a process +decision available today at no cost. Stages 4 and 5 are where the custody property lands. +After stage 5, surgical redeployment and the absence of custody admin powers coexist — the +two things currently treated as mutually exclusive. + +## Open questions + +1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` + (suggested: aligned with the current freeze horizon), `MAX_SLASH_PER_EPOCH`, + `MAX_POT_FRACTION_PER_ROUND`, `MAX_PRICE`, `CUTOVER_NOTICE`. These are immutable once + deployed and so need their own analysis. +2. **Postage exit economics.** What forfeit fraction or minimum batch age makes + `refundBatch` non-abusable without making it useless as an escape hatch? +3. **Stranded pot.** Where does the residual pot in a retired core go, given that by + construction no one can direct it to an arbitrary address? +4. **Wind-down funding.** Should the F5 reduced-rate window be pre-funded from the treasury, + or is a fixed fraction of the live pot acceptable? +5. **Tail of the final batch migration.** Is a deadline-limited, deposit-matched + admin-assisted path acceptable, or must the final migration be fully user-driven even at + the cost of abandoning some batches? +6. **Multi-client discipline.** F1–F3 assume every client implements cutover identically. + What is the conformance mechanism if a second client exists? + +## References + +- [`ethersphere/storage-incentives#310`][pr310] — Versioned Registry Router + Upgradeable + Proxies for All Core Contracts, and the review discussion that motivated this SWIP. +- *Forking Swarm: A migration guide* — Andrew Macpherson, Shtuka Research (presentation, + 2026). Source of the fork framing, the v2.8.0 dissent measurements, and the + v0.9.3/v0.9.4 case study. Not yet published at a stable URL; to be linked or mirrored + under `SWIPs/assets/swip-67/` with the author's consent. +- Deployed contracts referenced throughout: `src/PostageStamp.sol`, `src/Staking.sol`, + `src/Redistribution.sol` in `ethersphere/storage-incentives`. + +[pr310]: https://github.com/ethersphere/storage-incentives/pull/310 + +## Acknowledgements + +Part 2 is substantially derived from Andrew Macpherson's *Forking Swarm* presentation +(Shtuka Research) and from his review of [`storage-incentives#310`][pr310]. Specifically +his: the fork and fork-migration framing (F0); the argument that contract identity rather +than wire version is what partitions the incentive game, and hence F1; the v2.8.0 dissent +measurements and the v0.9.3/v0.9.4 case study; the assessment that upgradeable staking is a +strict increase in attack surface, from burn to steal; and the observation that the interval +between a client release and the pausing of the old stake registry is network downtime. + +Note that this SWIP departs from *Forking Swarm* on one conclusion: that document argues +that phasing out admin powers makes surgical redeployment impossible and therefore requires +full-suite redeployment with batch and stake migration at every fork. Part 1 argues the +coupling that makes this true is removable, and Part 2 is adapted accordingly. Co-authorship +is listed on the strength of the derived material; @awmacpherson should feel free to ask for +his name to be removed if he does not want to be associated with that departure. + +## Copyright + +Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 4fbdb32c6da48f76a86a015709813bac97b6af9e Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 7 Sep 2026 11:55:34 +0200 Subject: [PATCH 02/24] swip-67: reframe #310 as upgradeability, drop downtime attribution storage-incentives#310 is an upgradeability proposal, not a security one; describe it that way rather than as "a security thread". Remove the claim that full redeployment cost the network ten days of downtime and half its target replication. The 2025 gap was a consequence of the old stake registry's pause being scheduled well after the client release, not a lower bound on migration cost. The v0.9.3/v0.9.4 evidence is kept, but recast as two scheduling failures that F3/F4 fix by construction, and the argument for Part 1 no longer leans on it. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-67.md | 57 ++++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index b7165390..b2303a1d 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -24,8 +24,9 @@ place. That single fact causes both of our recurring problems: gave admins powers over money. The redistributor role can send the entire postage pot to any address; the admin role can mint batch state that no one paid for. - **Migrations.** When we refuse to use those powers, we must instead move everyone's - money — and a stake migration alone has cost the network ten days and half its target - replication. + money. Every logic change becomes a fund movement for every user and every operator, + which is why we keep avoiding it, and why no batch migration has ever been completed + without admin-driven cloning. This SWIP splits each contract in two. A **core** holds the money, has no admin, is never upgraded, and enforces its own accounting invariants. A **policy** holds the rules, is @@ -63,19 +64,18 @@ at a new policy address". ### The two problems are one problem -Two threads have been running in parallel: a security thread about admin powers and -upgradeability (see [`storage-incentives#310`][pr310]), and a migration thread about how -we roll out new network versions (see *Forking Swarm*). They are the same problem -seen from two sides. +Two threads have been running in parallel: an upgradeability thread (see +[`storage-incentives#310`][pr310]), and a migration thread about how we roll out new +network versions (see *Forking Swarm*). They are the same problem seen from two sides. Because state and logic live in the same contract, replacing logic means replacing state. Replacing state means a migration. Avoiding the migration means giving an admin a shortcut over state — which is a power over funds. So we oscillate between two bad options: 1. **Use the admin shortcut.** Cheap, but the admin can steal the pot and burn all stake. -2. **Do a full redeployment and migrate everything.** Rug-resistant, but it has cost the - network real downtime and real money, and we have never once managed a batch migration - without admin-driven cloning. +2. **Do a full redeployment and migrate everything.** Rug-resistant, but it turns every + logic change into a fund movement for every user and every operator, and no batch + migration has ever been completed without admin-driven cloning. The conclusion drawn in *Forking Swarm* — that phasing out admin powers makes surgical redeployment impossible, so every upgrade must become a full-suite redeployment @@ -118,7 +118,7 @@ would be a strict increase in attack surface, from "burn" to "steal". the scenario the hatch exists for — the admin is the adversary — the hatch is closed by the adversary. An escape hatch gated on a privileged role is not an escape hatch. -### What migrations have cost +### What has actually gone wrong From *Forking Swarm*: @@ -128,17 +128,21 @@ From *Forking Swarm*: identities; nine rounds in three weeks (0.38%) in which a dissenter was leader. In round 306865 a dissenter revealed depth 10, so every node was frozen for twice as long and the depth floor blocked all nodes from the following round. -- **Staggered surgical redeployment, v0.9.3/v0.9.4 (2025-06 to 2025-08).** Two live - redistributors for three weeks (~15 BZZ bled). Then a six-day window in which operators - who *had* upgraded could not earn, because stake migration cannot begin until the old - registry is paused. Roughly half of nodes migrated within three days of unpausing and - most of the rest a week later. For a week the publicly advertised branch of Swarm was - effectively a Foundation-operated cloud service; for the following week it ran at half - target replication. - -The sharpest framing in that document is that **any time between a new client release and -the pausing of the old stake registry is network downtime**. Migration is not an -inconvenience to be scheduled; it is an outage to be designed away. +- **Staggered surgical redeployment, v0.9.3/v0.9.4 (2025).** Two redistributors were + authorised on the same `PostageStamp` at once for three weeks, and the resulting race + bled roughly 15 BZZ from operators on the production branch. Separately, the pausing of + the old stake registry was scheduled well after the corresponding client release, so + operators who had upgraded were unable to earn until it happened. + +Neither of these is evidence that migration is inherently slow or expensive. Both are +scheduling failures: overlapping authority that should have been singleton, and a cutover +that was staggered when it should have been atomic. They are cited here because F3 and F4 +remove both by construction, not to argue that migrations cost weeks. + +The structural point worth keeping from *Forking Swarm* is that **the interval between a +new client release and the pausing of the old stake registry is dead time for everyone who +has upgraded**. Its length in any given rollout is a matter of scheduling; the remedy is to +make the interval zero by construction rather than to try to keep it short. ### Why not simply put everything behind proxies @@ -339,7 +343,7 @@ and height from it on first use, so that a fork requires no operator transaction Note that `Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before participation; inheriting predecessor state avoids re-triggering that delay, whereas a fresh declaration would cost operators roughly two rounds (~25 minutes at -`ROUND_LENGTH = 152` on Gnosis) rather than the ten days observed in 2025. +`ROUND_LENGTH = 152` on Gnosis). #### C4. `PostageAccounting` interface @@ -551,8 +555,8 @@ Together: - Deposits never move, so cutover involves no user or operator fund transaction (F6.4). - `Redistribution` identity still changes per fork, so branches never share a game (F1). - Batches carry across, so there is no batch migration and `copyBatch` can be retired. -- The window "between release and pausing the old registry" — the observed downtime — - collapses to zero. +- The interval "between release and pausing the old registry" collapses to zero, because + there is nothing to pause and nothing to move. ## Rationale @@ -580,7 +584,7 @@ temptation. **Why staking first.** It is the case where the target property is cleanest (funds already only flow to `msg.sender`), it is the case where the objection to upgradeability was strongest, and demonstrating a frozen core there earns the standing to freeze the postage -ledger afterwards. It is also the case that removes the observed downtime. +ledger afterwards. **Why bounds rather than prohibitions.** A design in which policy has no authority at all over funds cannot slash, cannot pay winners, and is therefore not an incentive system. The @@ -725,7 +729,8 @@ his: the fork and fork-migration framing (F0); the argument that contract identi than wire version is what partitions the incentive game, and hence F1; the v2.8.0 dissent measurements and the v0.9.3/v0.9.4 case study; the assessment that upgradeable staking is a strict increase in attack surface, from burn to steal; and the observation that the interval -between a client release and the pausing of the old stake registry is network downtime. +between a client release and the pausing of the old stake registry is dead time for +upgraded operators. Note that this SWIP departs from *Forking Swarm* on one conclusion: that document argues that phasing out admin powers makes surgical redeployment impossible and therefore requires From 10cb7dacae5d1bc7cec710905e301a7ae1a918e8 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 7 Sep 2026 11:59:51 +0200 Subject: [PATCH 03/24] swip-67: accumulator into the core, plus cutover typology and staking fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness fix. The first draft put price ingestion in PostagePolicy. A batch's normalisedBalance is denominated in the issuing contract's outpayment accumulator, so a policy-side accumulator would rebase every batch on every policy replacement — wrong expiry and premature reserve eviction, once per upgrade instead of once per migration. The accumulator and normalised-balance arithmetic move into PostageAccounting, and the frozen-outpayment-model consequence is stated (C2.7, C4). Follow-on: `accrue` is gone. The core derives remaining balances itself, and `expire` is permissionless and self-verifying, so policy has no pot-accrual authority at all. C3.1 eligibility clock: StakingPolicy counts eligibility from min(depositBlock, preRegistrationBlock), so a mass re-staking event does not open a rolling participation trough. Staggering to avoid a gas spike lengthens the trough rather than fixing it. C3.2 accounts and nodes: deposits are per account, overlay mapping is policy-side. Fleet operations scale with accounts, withdrawal authority separates from the node signer, and the shared-account slashing question is recorded as open. F7 cutover typology: Type A (wire-breaking, single game ABI, no dual-mode) vs Type B (contract-only, dual bindings required). F7.1 requires any cutover needing a branch in consensus-critical computation to be Type A, since a dual-mode sampler is itself a dissent source. Old F7 renumbered to F8. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-67.md | 181 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 159 insertions(+), 22 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index b2303a1d..83abee6e 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -192,7 +192,7 @@ Each fund-holding contract is split into two deployed contracts. | Core (frozen, no admin, holds BZZ) | Policy (replaceable, holds no BZZ) | |---|---| -| `PostageAccounting` — batch ownership, per-batch balance, pot, total deposited, total paid out | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price ingestion, expiry ordering | +| `PostageAccounting` — batch ownership, per-batch normalised balance, the outpayment accumulator, pot, total deposited, total paid out | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price submission, expiry ordering | | `StakingCore` — per-address deposit, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | `Redistribution` and `PriceOracle` are policy-class contracts: they hold no user funds and @@ -228,6 +228,9 @@ total paid out, and MUST maintain, checked at the end of every state-changing ca sum(recorded claims) + pot <= token.balanceOf(core) ``` +The invariant MUST be maintained incrementally on each call, not recomputed by iterating +balances, and MUST be the subject of the fuzz coverage required by [Test cases](#test-cases). + The core MUST own exactly enough arithmetic to police this and no more. In particular, pot growth MUST be bounded by the core independently of policy's accounting: policy may *assert* an accrual, but the core MUST reject any accrual that would breach the inequality above. @@ -246,7 +249,8 @@ rate-limited by the core: |---|---| | `claimPot()` | at most `MAX_POT_FRACTION_PER_ROUND` of `pot` per `ROUND_LENGTH` window | | `slash(node, amount)` | at most `MAX_SLASH_PER_EPOCH` per node and in aggregate per epoch | -| pot accrual | bounded by C2.2, and by `MAX_PRICE` on the ingested price | +| `setPrice(price)` | `price <= MAX_PRICE`, and the step from `lastPrice` at most `MAX_PRICE_CHANGE_PER_UPDATE` | +| pot accrual | not a policy primitive at all — see C4 | Suggested initial values are given in [Open questions](#open-questions); they are parameters of the deployment, immutable in the core once set. The purpose of the bounds is @@ -290,11 +294,33 @@ This is an economic parameter, not a security one, and is listed as an open ques takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin is worse than an upgradeable contract. Therefore: -- Cores hold balances, ownership, monotone accumulators, and the conservation check. Nothing - else. +- Cores hold balances, ownership, monotone accumulators, the outpayment accumulator, and + the conservation check. Nothing else. - Everything with interesting edge cases — the expiry ordering structure, batch selection, depth and bucket rules, effective-stake curves, commitment maths — lives in policy, where it can be fixed. + +The outpayment accumulator is the one piece of pricing arithmetic that cannot live in the +replaceable half, and the reason is worth stating precisely. A batch's `normalisedBalance` +is denominated *in the accumulator of the contract that issued it*: + +``` +currentTotalOutPayment() = totalOutPayment + lastPrice * (block.number - lastUpdatedBlock) +remainingBalance(id) = normalisedBalance[id] - currentTotalOutPayment() +``` + +A fresh contract starts the accumulator at zero, so every balance must be *rebased*, not +re-pointed — which is exactly what today's `copyBatch` does when it recomputes +`normalisedBalance = currentTotalOutPayment() + remainingBalance`. If the accumulator lived +in policy, every policy replacement would rebase every batch, turning a once-per-migration +hazard into a once-per-upgrade one: wrong expiry and premature reserve eviction. That would +be strictly worse than the status quo. + +The cost of putting it in the core is that **the outpayment model itself is frozen**: linear +per-block accrual against a per-chunk normalised balance. Moving to a different model — +non-linear pricing, per-neighbourhood pricing, a different unit of account — is not a policy +change and would still require a migration. This is the largest single thing the proposal +gives up, and it is deliberate. - Cores MUST be formally specified and MUST have full invariant and fuzz coverage before deployment (see [Test cases](#test-cases)). @@ -345,6 +371,37 @@ participation; inheriting predecessor state avoids re-triggering that delay, whe fresh declaration would cost operators roughly two rounds (~25 minutes at `ROUND_LENGTH = 152` on Gnosis). +**C3.1 — Eligibility clock.** `StakingPolicy` MUST compute participation eligibility from +`min(depositBlock, preRegistrationBlock)`, where pre-registration is a zero-value +transaction an operator MAY send in advance of a deposit or a cutover. + +`Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before a node may +participate. Without a pre-registration clock, any event that causes many operators to +establish a stake record at similar times produces a **rolling participation trough**: for +the duration of the spread, effective participation is a fraction of normal, and with few +participants a single dissenter's chance of being leader rises sharply — which is the +v2.8.0 failure mode, self-inflicted. Note that staggering such an event to avoid a gas +spike makes the trough *worse*, not better, by lengthening it. Pre-registration lets the +settling period elapse before the event, so no operator waits at cutover and no trough is +created. + +**C3.2 — Accounts and nodes.** `StakingCore` MUST record deposits per *account* and MUST NOT +assume a one-to-one relationship between an account and a node identity. Mapping an account +to one or more node overlays is `StakingPolicy`'s responsibility, since overlay derivation +is already policy-side. + +This is close to free once overlay lives in policy, and it has three consequences worth +naming: fleet operations become proportional to accounts rather than nodes, so a large +operator can fund or exit an entire fleet in one transaction; withdrawal authority is +separated from the node's operational signer, so a compromised node key cannot move funds; +and the cost of the one final stake migration falls sharply. + +It introduces one question the policy MUST answer explicitly: if several nodes are backed by +one account, a slash earned by one node reduces the stake backing the others. Acceptable +answers include per-node sub-allocations within an account, or requiring an account's +deposit to cover the sum of its nodes' committed stakes. This SWIP does not pick one; see +[Open questions](#open-questions). + #### C4. `PostageAccounting` interface ```solidity @@ -360,10 +417,15 @@ interface IPostageAccounting { /// May forfeit a fixed fraction to the pot (see C2.6). function refundBatch(bytes32 batchId) external; + /// @notice Credit the pot with the residual value of expired batches. + /// Permissionless. For each id the core verifies remainingBalance(id) == 0 + /// for itself; ordering hints from policy are not trusted. + function expire(bytes32[] calldata batchIds) external; + // ---- policy, bounded (C2.4) ---- - /// @notice Debit a batch and credit the pot. Reverts if the conservation - /// invariant (C2.2) or MAX_PRICE would be breached. - function accrue(bytes32 batchId, uint256 amount) external; + /// @notice Submit a new price. The core folds it into its own accumulator. + /// Bounded by MAX_PRICE and MAX_PRICE_CHANGE_PER_UPDATE. + function setPrice(uint256 price) external; /// @notice Pay out to the single authorised redistributor. Capped per round (C2.4). /// Destination is not a parameter. @@ -374,22 +436,32 @@ interface IPostageAccounting { function executeRedistributor() external; // ---- views ---- - function balanceOf(bytes32 batchId) external view returns (uint256); + function remainingBalance(bytes32 batchId) external view returns (uint256); + function normalisedBalanceOf(bytes32 batchId) external view returns (uint256); + function currentTotalOutPayment() external view returns (uint256); function ownerOf(bytes32 batchId) external view returns (address); function pot() external view returns (uint256); function redistributor() external view returns (address); } ``` -`claimPot` takes an amount but not a destination. There is no `withdraw(address)`. The -redistributor pointer is singleton by construction rather than by role hygiene, which is -the direct fix for the v0.9.3 double-redistributor race. +There is no `withdraw(address)`. The redistributor pointer is singleton by construction +rather than by role hygiene, which is the direct fix for the v0.9.3 double-redistributor +race. + +`claimPot` takes an amount but not a destination, and there is no `accrue` primitive: pot +growth is not something policy can assert. The core derives every batch's remaining balance +from its own accumulator, and `expire` is permissionless and self-verifying — a caller +supplies candidate batch ids, and the core credits the pot only for ids it independently +confirms have reached zero. Policy therefore has no pot-accrual authority whatsoever, which +is a strict reduction in policy authority relative to the first draft of this SWIP. Batch *identity and semantics* — bucket depth validity, immutability flags, minimum initial -balance, depth-increase rules — live in `PostagePolicy`. `PostageAccounting` records only -that a batch id is owned by an address and holds a balance. The expiry ordering structure -(today `HitchensOrderStatisticsTreeLib`) lives in policy; the core does not need it, because -under C2.2 it bounds pot growth by conservation rather than by recomputing expiry. +balance, depth-increase rules — live in `PostagePolicy`. The expiry *ordering* structure +(today `HitchensOrderStatisticsTreeLib`) also lives in policy: it is a search index over +core state, rebuildable from events, and it is the single most edge-case-heavy component in +the current contract, so it belongs in the half that can be fixed. Ordering is a hint; +`expire` verifies. #### C5. Residual trust after Part 1 @@ -546,7 +618,40 @@ A conforming client: 5. SHOULD expose the pending cutover in its status API and log a warning when it is running a version whose cutover has passed. -#### F7. Relationship between the parts +#### F7. Cutover types and dual-ABI scope + +Two kinds of cutover exist and they carry different client obligations. Conflating them is +what makes the dual-ABI burden look unbounded. + +**Type A — wire-breaking.** The release changes the p2p protocol version, so vN and vN+1 +nodes cannot peer at all. The client ships a *single* game ABI. A node that has not upgraded +by `activationBlock` stops earning, which is intended and is the entire content of F1. No +dual-mode code is required, because a non-upgraded node is on the other branch and must not +be paid from this branch's pot. + +**Type B — contract-only.** The wire protocol is unchanged: a `Redistribution` bugfix, a +policy parameter change, a new `PostagePolicy`. Continuity is expected — operators who have +not restarted MUST keep earning across `activationBlock` — so the client MUST carry both +contract bindings and switch at `activationBlock`. The legacy binding MAY be removed in the +first release after the cutover. + +**F7.1 — Consensus-path rule.** A cutover that would require a runtime branch in +consensus-critical computation — reserve sampling, commitment hashing, overlay derivation, +depth or eligibility determination — MUST be Type A. It MUST NOT be shipped as Type B with a +runtime branch. + +The reason is that a dual-mode sampler is itself a source of dissent: two nodes that +disagree about which mode they are in produce divergent reserve commitments, which is +precisely the failure mode F1 exists to prevent. F7.1 confines Type B's dual-mode surface to +contract call sites, where it is cheap, and pushes anything deeper into Type A, where the +network partition already does the separating. It converts "supporting two ABIs is +unbounded maintenance" from an objection into a design constraint that stops the expensive +case from arising. + +Under Part 1 the frozen cores never acquire a second ABI, so deposits, withdrawals and +balance reads never branch in either type. Only policy and `Redistribution` bindings do. + +#### F8. Relationship between the parts Part 2 alone still requires stake migration at every fork, which is the ten-day outage. Part 1 alone leaves the fork boundary undefined, so wire-only forks keep commingling incentives. @@ -667,10 +772,26 @@ cutover proposed off-boundary MUST revert; the old redistributor MUST reject com final round and MUST still accept reveals and claims for the round already committed; a `manifest` mismatch MUST cause client hard-failure. +**Accumulator continuity tests.** A policy replacement MUST NOT change +`currentTotalOutPayment()`, `normalisedBalanceOf()` or `remainingBalance()` for any batch. +This MUST be tested across a policy change with a pending price update, and across a policy +change that occurs mid-expiry. + +**Expiry self-verification tests.** `expire()` MUST credit the pot only for batch ids whose +`remainingBalance()` the core independently computes as zero, and MUST be safe when passed +arbitrary, duplicated, non-existent or not-yet-expired ids by an untrusted caller. + +**Price bound tests.** `setPrice` MUST reject a price above `MAX_PRICE` or a step above +`MAX_PRICE_CHANGE_PER_UPDATE`, from an honest and a malicious policy alike. + +**Eligibility clock tests.** A pre-registered operator MUST be eligible at +`activationBlock` without a settling delay (C3.1); a non-pre-registered operator MUST NOT +be. + **Fuzz and differential.** Fuzz the conservation invariant across randomised sequences of -deposit, top-up, accrue, claim, slash, withdraw and exit. Differentially test -`PostagePolicy` accrual against the current `PostageStamp` expiry logic over historical -batch data, to confirm the split preserves today's accounting. +deposit, top-up, price update, expire, claim, slash, withdraw and exit. Differentially test +core accounting against the current `PostageStamp` over historical batch data, to confirm +the split preserves today's remaining-balance and expiry results exactly. ## Implementation @@ -680,7 +801,7 @@ Staged so that each stage is independently valuable and independently revertible |---|---|---| | 1 | Surgical `Redistribution` redeployment with security fixes; round-aligned atomic cutover; single redistributor; stake and batches untouched | — | | 2 | F1 adopted as standing practice: new `Redistribution` on every breaking wire release | — | -| 3 | `Cutover` contract and client support (F2, F3, F6). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | +| 3 | `Cutover` contract and client support (F2, F3, F6, F7). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | | 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | | 5 | `PostageAccounting` + `PostagePolicy`. Final batch migration. `copyBatch` retired | 4 | | 6 | `POLICY_TIMELOCK` extended; governing multisig scope reduced to policy pointers only | 5 | @@ -694,8 +815,8 @@ two things currently treated as mutually exclusive. 1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` (suggested: aligned with the current freeze horizon), `MAX_SLASH_PER_EPOCH`, - `MAX_POT_FRACTION_PER_ROUND`, `MAX_PRICE`, `CUTOVER_NOTICE`. These are immutable once - deployed and so need their own analysis. + `MAX_POT_FRACTION_PER_ROUND`, `MAX_PRICE`, `MAX_PRICE_CHANGE_PER_UPDATE`, + `CUTOVER_NOTICE`. These are immutable once deployed and so need their own analysis. 2. **Postage exit economics.** What forfeit fraction or minimum batch age makes `refundBatch` non-abusable without making it useless as an escape hatch? 3. **Stranded pot.** Where does the residual pot in a retired core go, given that by @@ -707,6 +828,12 @@ two things currently treated as mutually exclusive. the cost of abandoning some batches? 6. **Multi-client discipline.** F1–F3 assume every client implements cutover identically. What is the conformance mechanism if a second client exists? +7. **Shared-account slashing.** Under C3.2, how is a slash apportioned when one account + backs several nodes — per-node sub-allocations, or a coverage requirement on the + account's deposit? +8. **Frozen outpayment model.** The accumulator in the core freezes linear per-block + accrual (C2.7). Is that the model we want to commit to indefinitely, and if not, what + is the minimal generalisation worth freezing instead? ## References @@ -732,6 +859,16 @@ strict increase in attack surface, from burn to steal; and the observation that between a client release and the pausing of the old stake registry is dead time for upgraded operators. +Review of the first draft materially changed Part 1. Mark Bliss identified that a batch's +remaining balance and expiry are computed against the issuing contract's outpayment +accumulator, so switching contracts requires derived state to be rebased rather than +re-pointed — which establishes that the accumulator cannot live in the replaceable half +(C2.7, C4), and that a policy-side accumulator would have been strictly worse than the +status quo. The same review supplied the partial-batch-set data-loss window, the +observation that a half-completed migration and a precompiled fork height are in direct +contradiction, and the dual-ABI maintenance argument that F7.1 answers. (GitHub handle to +be added.) + Note that this SWIP departs from *Forking Swarm* on one conclusion: that document argues that phasing out admin powers makes surgical redeployment impossible and therefore requires full-suite redeployment with batch and stake migration at every fork. Part 1 argues the From ba5f18b888d6fb3c40e2825aa778d0c01f94c6fa Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 7 Sep 2026 12:07:28 +0200 Subject: [PATCH 04/24] swip-67: contents, normative index, and an editing pass Adds a Contents block and a "Normative requirements at a glance" table so an 880-line spec can be navigated and reviewed rule by rule. Fixes two defects introduced by the previous edit: a list item in C2.7 was orphaned below the accumulator discussion, breaking the list, and F8 still described a stake migration as "the ten-day outage" after that attribution was removed everywhere else. Prose: breaks the Abstract's Part 1 sentence, removes first-person hedging in C2.5, C5, Motivation and Rationale, and normalises cross-references to bare rule ids. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-67.md | 87 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 24 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 83abee6e..e848f526 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -15,6 +15,20 @@ shortcut around a fund migration is an admin power over funds. This SWIP separat custody from policy so that logic can be replaced without moving funds, and specifies the fork-migration protocol that replacement runs under. --> +## Contents + +- [Simple Summary](#simple-summary) · [Abstract](#abstract) +- [Motivation](#motivation) — why the security and migration problems are one problem, and + where the custody surface is in today's deployed code +- [Specification](#specification) + - [Normative requirements at a glance](#normative-requirements-at-a-glance) + - [Part 1 — Custody separation](#part-1--custody-separation) (C1–C5) + - [Part 2 — Fork migration](#part-2--fork-migration) (F0–F8) +- [Rationale](#rationale) — design choices and rejected alternatives +- [Backwards compatibility](#backwards-compatibility) — the two final migrations +- [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) +- [References](#references) · [Acknowledgements](#acknowledgements) + ## Simple Summary Swarm's storage-incentive contracts keep user money and changeable rules in the same @@ -41,12 +55,14 @@ We specify two coupled changes to the storage-incentive contract suite. **Part 1 — Custody separation.** `PostageStamp` and `StakeRegistry` are each split into a frozen custody core (`PostageAccounting`, `StakingCore`) and a replaceable policy contract -(`PostagePolicy`, `StakingPolicy`). Cores hold all BZZ, expose no function that transfers -to a caller-supplied address, enforce token-conservation invariants against their own -recorded state, rate-limit every value-moving primitive a policy can trigger, change their -policy pointer only through a self-enforced timelock, never call into policy, and offer a -permissionless exit that no role can pause. Policies hold batch admissibility, pricing, -overlay derivation, commitment and effective-stake maths, and slashing rules. +(`PostagePolicy`, `StakingPolicy`). + +Cores hold all BZZ. They expose no function that transfers to a caller-supplied address; +they enforce token conservation against their own records; they rate-limit every +value-moving primitive a policy can trigger; they change their policy pointer only through +a timelock they enforce themselves; they never call into policy; and they offer a +permissionless exit that no role can pause. Policies hold batch admissibility, price +submission, overlay derivation, commitment and effective-stake maths, and slashing rules. **Part 2 — Fork migration.** Every breaking wire-protocol release MUST be accompanied by a new `Redistribution` deployment, even when its code is unchanged, so that the two branches @@ -148,8 +164,8 @@ make the interval zero by construction rather than to try to keep it short. [`storage-incentives#310`][pr310] proposes upgradeable proxies for all core contracts plus an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path that -lets a client pin an expected implementation atomically. The reviewer objections to that -approach are, in our assessment, correct, and this SWIP is the alternative: +lets a client pin an expected implementation atomically. The objections raised in review of +that approach hold, and this SWIP is the alternative: - A proxy over a fund-holding contract hands the proxy admin the ability to steal those funds. For `StakeRegistry` this converts today's "admin can burn stake" into "admin can @@ -167,8 +183,8 @@ approach are, in our assessment, correct, and this SWIP is the alternative: The on-chain registry does have real value, but it is coordination and observability value, not security value: the trust root for which contracts a node talks to is the client release process either way. This SWIP therefore keeps a registry-like contract and gives it -the job it is actually good at — signalling cutover timing (Part 2, F2) — and drops the -guarded proxy and `pinnedExecute`. +the job it is actually good at — signalling cutover timing (F2) — and drops the guarded +proxy and `pinnedExecute`. ### What this SWIP does not claim @@ -184,6 +200,29 @@ forks expensive enough to avoid. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119. +### Normative requirements at a glance + +| | Requirement | +|---|---| +| **C1** | Each fund-holding contract splits into a frozen core and a replaceable policy. Cores are never deployed behind a proxy and contain no `delegatecall`. | +| **C2.1** | No core function transfers to a caller-supplied address. Every destination derives from core state. | +| **C2.2** | The core enforces `recorded claims + pot <= token balance` itself, incrementally, on every call. | +| **C2.3** | Calls go policy → core only. No callbacks, no core reads of policy, no dependence of core correctness on policy code. | +| **C2.4** | Every value-moving primitive policy can trigger is rate-limited by the core. | +| **C2.5** | The policy pointer changes only after a timelock the core enforces with an immutable constant. | +| **C2.6** | Each core offers an exit with no role check, no pause, and no dependence on policy state. | +| **C2.7** | Cores have no upgrade path, so they stay minimal. The outpayment accumulator lives in the core, which freezes the outpayment model. | +| **C3.1** | Participation eligibility counts from `min(depositBlock, preRegistrationBlock)`. | +| **C3.2** | Deposits are recorded per account, not per node. Overlay mapping is policy-side. | +| **C4** | `claimPot` takes no destination; there is no `accrue`; `expire` is permissionless and self-verifying; `setPrice` is bounded. | +| **F1** | Every breaking wire release deploys a new `Redistribution`, even if the bytecode is unchanged. | +| **F2.1** | The chain signal carries timing. Contract addresses are compiled into the client. | +| **F2.2** | Clients determine activation by observing `Cutover` state, never by a height baked into the binary. | +| **F3** | `activationBlock` falls on a round boundary, and the outgoing redistributor stops accepting commits one round early. | +| **F4** | At most one redistributor is authorised at any block, enforced by type rather than by role hygiene. | +| **F6** | Clients read timing from chain, addresses from the binary, and send no fund-moving transaction in response to a chain signal. | +| **F7.1** | A cutover needing a runtime branch in consensus-critical computation is wire-breaking (Type A) and ships a single game ABI. | + ### Part 1 — Custody separation #### C1. Structure @@ -266,11 +305,11 @@ pointer to change, and if it does: that a role could replace; - `POLICY_TIMELOCK` MUST be immutable. -We state plainly what this is and is not. A core with a timelocked policy pointer **has a -privileged operation**; it is not literally admin-free. The claim being made is narrower and -checkable: *no privileged operation can move a user's deposit, and every privileged -operation is announced in advance with a guaranteed exit window*. Proposals that describe -this as "no admins" should be corrected to this formulation. +To be precise about what this is: a core with a timelocked policy pointer **has a privileged +operation**, and is not literally admin-free. The claim is narrower and checkable — *no +privileged operation can move a user's deposit, and every privileged operation is announced +in advance with a guaranteed exit window*. Descriptions of this design as "no admins" should +be corrected to that formulation. **C2.6 — Permissionless exit.** Each core MUST provide an exit that: @@ -299,6 +338,8 @@ worse than an upgradeable contract. Therefore: - Everything with interesting edge cases — the expiry ordering structure, batch selection, depth and bucket rules, effective-stake curves, commitment maths — lives in policy, where it can be fixed. +- Cores MUST be formally specified and MUST have full invariant and fuzz coverage before + deployment (see [Test cases](#test-cases)). The outpayment accumulator is the one piece of pricing arithmetic that cannot live in the replaceable half, and the reason is worth stating precisely. A batch's `normalisedBalance` @@ -321,8 +362,6 @@ per-block accrual against a per-chunk normalised balance. Moving to a different non-linear pricing, per-neighbourhood pricing, a different unit of account — is not a policy change and would still require a migration. This is the largest single thing the proposal gives up, and it is deliberate. -- Cores MUST be formally specified and MUST have full invariant and fuzz coverage before - deployment (see [Test cases](#test-cases)). #### C3. `StakingCore` interface @@ -480,9 +519,9 @@ Stated explicitly so it can be argued with: The row that does not go away is the last-but-one: whoever controls policy can still bias who wins the pot, which is an indirect claim on future revenue. **Custody separation protects deposits, not rewards.** Bounding reward direction further would require freezing -redistribution verification itself, which conflicts directly with Part 2's requirement that -`Redistribution` be redeployed per fork. We consider the trade correct and name it rather -than paper over it. +redistribution verification itself, which conflicts directly with F1's requirement that +`Redistribution` be redeployed per fork. The trade is deliberate, and named here rather than +left implicit. ### Part 2 — Fork migration @@ -653,9 +692,9 @@ balance reads never branch in either type. Only policy and `Redistribution` bind #### F8. Relationship between the parts -Part 2 alone still requires stake migration at every fork, which is the ten-day outage. Part -1 alone leaves the fork boundary undefined, so wire-only forks keep commingling incentives. -Together: +Part 2 alone still requires a stake migration at every fork, and therefore an interval in +which upgraded operators cannot earn. Part 1 alone leaves the fork boundary undefined, so +wire-only forks keep commingling incentives. Together: - Deposits never move, so cutover involves no user or operator fund transaction (F6.4). - `Redistribution` identity still changes per fork, so branches never share a game (F1). @@ -678,7 +717,7 @@ keep earning, but a user who fails to migrate a batch loses availability they ma until they need the data. Operators follow money; users follow nothing. Part 1 removes the requirement rather than solving the coordination problem. -**Why the registry survives as a cutover signal.** The reviewer question on +**Why the registry survives as a cutover signal.** The question raised on [`storage-incentives#310`][pr310] — who benefits from an on-chain registry, and how does it compare to publishing under ENS or on GitHub — has a straight answer: for *security* it adds nothing, because the trust root is the client release process either way. For *coordination* From 010253fdf00fae4b107eac7c703eed7f28bcbf23 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Mon, 7 Sep 2026 16:28:49 +0200 Subject: [PATCH 05/24] =?UTF-8?q?swip-67:=20audit=20pass=20=E2=80=94=20fix?= =?UTF-8?q?=20contradictions,=20factual=20errors,=20and=20cut=20fluff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factual corrections, checked against the deployed sources: - StakeRegistry declares no PAUSER_ROLE; pause() checks DEFAULT_ADMIN_ROLE and the OnlyPauser() error name is misleading. Corrected in Motivation and in the C5 residual-trust table. - remainingBalance clamps at zero; the quoted formula now says so. - Overlay derivation is network-scoped, not bound to the wire protocol — NetworkId is admin-mutable via changeNetworkId. - The round-306865 freeze multiple is not derivable from the freeze formula alone, so the unquantified claim replaces it. Contradictions: - Simple Summary and the C1 table called cores "no admin", which C2.5 explicitly forbids. Both now say the core has no admin power over the money it holds. - Roadmap stage 6 proposed extending POLICY_TIMELOCK, which C2.5 declares immutable and C2.7 gives no upgrade path to change. - C2.2 said policy "may assert an accrual" while C4 says accrual is not a policy primitive at all. The clause is gone. - F5 claimed a "deliberate exception" to F4 and then argued no second authorisation exists. It is a payment overlap, not an authority overlap, and the funding assertion moves back to open question 4. - F7 Type B required un-restarted operators to keep earning, which F2.1 makes impossible; it now says operators running a release that carries both bindings. - Backwards compatibility put an admin-gated migration function inside the core; the assisted path now goes through the ordinary fund() call. - The StakingCore invariant test subtracted totalSlashed from a balance that still holds it, since slashing burns in place. Unenforceable or dead requirements: - F3 required the outgoing redistributor to stop accepting commits a round early. Commit, reveal and claim all fall inside one ROUND_LENGTH, so a boundary-aligned cutover already orphans nobody and the bullet only created a dead round. The matching cutover test went with it. - C2.4's per-node slash cap is unenforceable once C3.2 makes the core account-scoped, and one constant cannot be both the per-node and the aggregate cap. Reduced to a single aggregate bound. - C2.4 windows were tied to ROUND_LENGTH, which is per-branch and replaceable; they are now core-owned block counts. - C2.6 guarded against policy state the core cannot hold; it now names the lock, which is the real mechanism. - C2.1 wrote a prohibition with RFC 2119 MAY. Consistency: slash/lock/depositOf take an account, not a node (C3.2); claimPot(amount) and withdraw(amount) match the interface; C2.5 is retitled for the pointers it actually governs; exit() is the two-step requestExit form everywhere. Cuts: the v0.9.3 incident was told twice in Motivation; the Rationale proxies entry duplicated Motivation; the accumulator and copyBatch explanations were doubled. Assorted self-congratulation and announcement sentences removed throughout. 920 lines to 870. Co-Authored-By: Claude Opus 5 --- SWIPs/swip-67.md | 400 +++++++++++++++++++++-------------------------- 1 file changed, 175 insertions(+), 225 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index e848f526..7a5408f9 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -9,11 +9,8 @@ category: Core created: 2026-09-07 --- - + ## Contents @@ -21,7 +18,7 @@ the fork-migration protocol that replacement runs under. --> - [Motivation](#motivation) — why the security and migration problems are one problem, and where the custody surface is in today's deployed code - [Specification](#specification) - - [Normative requirements at a glance](#normative-requirements-at-a-glance) + - [Key normative requirements at a glance](#key-normative-requirements-at-a-glance) - [Part 1 — Custody separation](#part-1--custody-separation) (C1–C5) - [Part 2 — Fork migration](#part-2--fork-migration) (F0–F8) - [Rationale](#rationale) — design choices and rejected alternatives @@ -38,12 +35,10 @@ place. That single fact causes both of our recurring problems: gave admins powers over money. The redistributor role can send the entire postage pot to any address; the admin role can mint batch state that no one paid for. - **Migrations.** When we refuse to use those powers, we must instead move everyone's - money. Every logic change becomes a fund movement for every user and every operator, - which is why we keep avoiding it, and why no batch migration has ever been completed - without admin-driven cloning. + money — a fund movement for every user and every operator, every time the rules change. -This SWIP splits each contract in two. A **core** holds the money, has no admin, is never -upgraded, and enforces its own accounting invariants. A **policy** holds the rules, is +This SWIP splits each contract in two. A **core** holds the money, has no admin power over +it, is never upgraded, and enforces its own accounting invariants. A **policy** holds the rules, is freely replaceable, and can never name a payment destination. It then specifies the **fork-migration protocol** — how a new policy and a new Redistribution contract are cut over atomically at a round boundary, so that a protocol upgrade stops being a fund @@ -68,9 +63,9 @@ submission, overlay derivation, commitment and effective-stake maths, and slashi new `Redistribution` deployment, even when its code is unchanged, so that the two branches of the resulting network fork do not play the same redistribution game. Cutover is signalled on chain by a `Cutover` contract that publishes *timing only*; contract addresses -are carried in the client binary. Cutover MUST land on a round boundary, with the outgoing -redistributor refusing new commits one round early so the game drains rather than stops. -`PostageAccounting` enforces at most one authorised redistributor at any block. +are carried in the client binary. Cutover MUST land on a round boundary, so no node is +mid-game when authority changes. `PostageAccounting` enforces at most one authorised +redistributor at any block. Together the parts remove admin custody of deposits, bound admin influence over future rewards, and reduce a protocol upgrade from "everyone moves their money" to "clients point @@ -99,42 +94,39 @@ with batch and stake migration — is true of the *current* architecture but is architecturally necessary. It is a consequence of the coupling, not of the threat model. Break the coupling and both options improve at once. -### Where the custody surface actually is, in code +### Where the custody surface is, in code The following are properties of the deployed contracts as of writing, not hypotheticals. **`PostageStamp.withdraw(address beneficiary)`** is gated on `REDISTRIBUTOR_ROLE` and -transfers the whole of `totalPot()` to a caller-supplied address. One call, entire pot, any -destination. +transfers the whole of `totalPot()` to a caller-supplied address. **`REDISTRIBUTOR_ROLE` is an OpenZeppelin `AccessControl` role**, so any number of addresses -can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. This is not a theoretical -concern: during the v0.9.3/v0.9.4 rollout two live redistributors were authorised on the -same `PostageStamp` at once, and the resulting race bled roughly 15 BZZ from operators on -the production branch over three weeks (*Forking Swarm*, case study 2). +can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. Two were in fact +authorised at once during the v0.9.3/v0.9.4 rollout (see below). **`PostageStamp.copyBatch` and `copyBatchBulk`** are gated on `DEFAULT_ADMIN_ROLE` and create batch state — owner, depth, `normalisedBalance` — while incrementing `validChunkCount`, **without transferring any BZZ into the contract**. `totalPot()` returns -`min(pot, balance)`, so this cannot directly over-transfer; but unbacked chunks accrue pot -at the same rate as paid ones, so the admin can accelerate pot accrual against the deposits -of real batch owners. Any honest accounting of admin attack surface must include these -functions alongside redistributor assignment. They exist to facilitate exactly the batch +`min(pot, balance)`, so unbacked state cannot directly over-transfer; but unbacked chunks +accrue pot at the same rate as paid ones, so the admin can accelerate pot accrual against +the deposits of real batch owners. These functions exist to facilitate exactly the batch migrations this SWIP aims to make unnecessary. -**`StakeRegistry` is, by contrast, genuinely rug-resistant today.** No code path sends BZZ +**`StakeRegistry` is, by contrast, rug-resistant today.** No code path sends BZZ anywhere except back to `msg.sender` (`withdrawFromStake`, `migrateStake`), and `slashDeposit` only decrements the record without transferring, so slashed BZZ is burnt in -place rather than stolen. This property is worth stating precisely because it is the -property any change must preserve: making `StakeRegistry` upgradeable in the ordinary sense -would be a strict increase in attack surface, from "burn" to "steal". +place rather than stolen. This is the property any change must preserve: making +`StakeRegistry` upgradeable in the ordinary sense would be a strict increase in attack +surface, from "burn" to "steal". **The existing escape hatch does not survive its own threat model.** -`StakeRegistry.migrateStake()` is `whenPaused`, and `pause()` requires `PAUSER_ROLE`. In -the scenario the hatch exists for — the admin is the adversary — the hatch is closed by the -adversary. An escape hatch gated on a privileged role is not an escape hatch. +`StakeRegistry.migrateStake()` is `whenPaused`, and `pause()` requires `DEFAULT_ADMIN_ROLE` +(the contract declares no `PAUSER_ROLE`; the `OnlyPauser()` error name is misleading). In +the scenario the hatch exists for — the admin is the adversary — the hatch stays shut unless +the adversary opens it. -### What has actually gone wrong +### What has gone wrong From *Forking Swarm*: @@ -142,8 +134,8 @@ From *Forking Swarm*: a new `Redistribution`. Rounds with a dissenting reveal went from approximately zero per week to approximately twenty; 2.8% of rounds in the first week; 44 distinct dissenting identities; nine rounds in three weeks (0.38%) in which a dissenter was leader. In round - 306865 a dissenter revealed depth 10, so every node was frozen for twice as long and the - depth floor blocked all nodes from the following round. + 306865 a dissenter revealed depth 10, so nodes were frozen for longer and the depth floor + blocked all nodes from the following round. - **Staggered surgical redeployment, v0.9.3/v0.9.4 (2025).** Two redistributors were authorised on the same `PostageStamp` at once for three weeks, and the resulting race bled roughly 15 BZZ from operators on the production branch. Separately, the pausing of @@ -152,15 +144,14 @@ From *Forking Swarm*: Neither of these is evidence that migration is inherently slow or expensive. Both are scheduling failures: overlapping authority that should have been singleton, and a cutover -that was staggered when it should have been atomic. They are cited here because F3 and F4 -remove both by construction, not to argue that migrations cost weeks. +that was staggered when it should have been atomic. F3 and F4 remove both by construction. The structural point worth keeping from *Forking Swarm* is that **the interval between a new client release and the pausing of the old stake registry is dead time for everyone who has upgraded**. Its length in any given rollout is a matter of scheduling; the remedy is to make the interval zero by construction rather than to try to keep it short. -### Why not simply put everything behind proxies +### Why not put everything behind proxies [`storage-incentives#310`][pr310] proposes upgradeable proxies for all core contracts plus an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path that @@ -168,23 +159,21 @@ lets a client pin an expected implementation atomically. The objections raised i that approach hold, and this SWIP is the alternative: - A proxy over a fund-holding contract hands the proxy admin the ability to steal those - funds. For `StakeRegistry` this converts today's "admin can burn stake" into "admin can - steal stake". + funds. - Verifying the registry inside the proxy fallback taxes every user call and introduces a liveness hazard: a mistaken deprecation or a codehash mismatch reverts *all* user calls, including withdrawals. That layers an availability risk on top of the custody risk it is trying to mitigate. - `pinnedExecute` imposes a permanent selector-collision constraint on every future implementation ABI and adds a second delegatecall path parallel to the fallback. -- Most importantly, the machinery solves "the admin swapped the implementation under me". +- The machinery solves "the admin swapped the implementation under me". If user funds live in a contract that cannot be swapped, that event is no longer a fund-loss event, and the machinery is not needed. -The on-chain registry does have real value, but it is coordination and observability -value, not security value: the trust root for which contracts a node talks to is the client -release process either way. This SWIP therefore keeps a registry-like contract and gives it -the job it is actually good at — signalling cutover timing (F2) — and drops the guarded -proxy and `pinnedExecute`. +This SWIP therefore keeps a registry-like contract for the job it is good at — signalling +cutover timing (F2) — and drops the guarded proxy and `pinnedExecute`. See +[Rationale](#rationale) for why the registry is a coordination tool rather than a security +one. ### What this SWIP does not claim @@ -200,7 +189,7 @@ forks expensive enough to avoid. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in RFC 2119. -### Normative requirements at a glance +### Key normative requirements at a glance | | Requirement | |---|---| @@ -208,20 +197,20 @@ RFC 2119. | **C2.1** | No core function transfers to a caller-supplied address. Every destination derives from core state. | | **C2.2** | The core enforces `recorded claims + pot <= token balance` itself, incrementally, on every call. | | **C2.3** | Calls go policy → core only. No callbacks, no core reads of policy, no dependence of core correctness on policy code. | -| **C2.4** | Every value-moving primitive policy can trigger is rate-limited by the core. | +| **C2.4** | The core rate-limits every value-moving primitive policy can trigger. | | **C2.5** | The policy pointer changes only after a timelock the core enforces with an immutable constant. | | **C2.6** | Each core offers an exit with no role check, no pause, and no dependence on policy state. | -| **C2.7** | Cores have no upgrade path, so they stay minimal. The outpayment accumulator lives in the core, which freezes the outpayment model. | +| **C2.7** | Cores have no upgrade path, so they MUST stay minimal. The outpayment accumulator lives in the core, which freezes the outpayment model. | | **C3.1** | Participation eligibility counts from `min(depositBlock, preRegistrationBlock)`. | | **C3.2** | Deposits are recorded per account, not per node. Overlay mapping is policy-side. | | **C4** | `claimPot` takes no destination; there is no `accrue`; `expire` is permissionless and self-verifying; `setPrice` is bounded. | | **F1** | Every breaking wire release deploys a new `Redistribution`, even if the bytecode is unchanged. | | **F2.1** | The chain signal carries timing. Contract addresses are compiled into the client. | | **F2.2** | Clients determine activation by observing `Cutover` state, never by a height baked into the binary. | -| **F3** | `activationBlock` falls on a round boundary, and the outgoing redistributor stops accepting commits one round early. | +| **F3** | `activationBlock` falls on a round boundary, so no node is mid-game when authority changes. | | **F4** | At most one redistributor is authorised at any block, enforced by type rather than by role hygiene. | | **F6** | Clients read timing from chain, addresses from the binary, and send no fund-moving transaction in response to a chain signal. | -| **F7.1** | A cutover needing a runtime branch in consensus-critical computation is wire-breaking (Type A) and ships a single game ABI. | +| **F7.1** | A cutover needing a runtime branch in consensus-critical computation is wire-breaking, and ships a single game ABI. | ### Part 1 — Custody separation @@ -244,21 +233,20 @@ to be simpler in practice. #### C2. Core invariants -These are the substance of the proposal. A split that does not satisfy them buys nothing: -it relocates the trust boundary by one hop and leaves it exactly as wide. Note that the -current architecture already has the shape "frozen ledger, swappable policy" — a -`PostageStamp` that never changes, with a replaceable `Redistribution` authorised on it — -and it leaks full custody, because `withdraw(beneficiary)` is an unconstrained primitive. -The shape is not the property. The invariants are. +A split that does not satisfy them buys nothing: it relocates the trust boundary by one hop +and leaves it exactly as wide. The current architecture already has the shape "frozen +ledger, swappable policy" — a `PostageStamp` that never changes, with a replaceable +`Redistribution` authorised on it — and it leaks full custody anyway, because +`withdraw(beneficiary)` is an unconstrained primitive. -**C2.1 — No caller-supplied destinations.** No function on a core MAY transfer tokens to an -address supplied by the caller or by policy. Every destination MUST be derived from the -core's own recorded state: +**C2.1 — No caller-supplied destinations.** A core MUST NOT transfer tokens to an address +supplied by the caller or by policy. Every destination MUST be derived from the core's own +recorded state: -- `PostageAccounting.refundBatch(batchId)` pays `batches[batchId].owner`. -- `StakingCore.withdraw()` pays `msg.sender`. -- `PostageAccounting.claimPot()` pays the single authorised redistributor address, which is - itself set only via C2.5. +- `PostageAccounting.refundBatch(batchId)` pays `ownerOf(batchId)`. +- `StakingCore.withdraw(amount)` pays `msg.sender`. +- `PostageAccounting.claimPot(amount)` pays the single authorised redistributor address, + which is itself set only via C2.5. **C2.2 — Conservation, enforced by the core.** Each core MUST track total deposited and total paid out, and MUST maintain, checked at the end of every state-changing call: @@ -270,11 +258,9 @@ sum(recorded claims) + pot <= token.balanceOf(core) The invariant MUST be maintained incrementally on each call, not recomputed by iterating balances, and MUST be the subject of the fuzz coverage required by [Test cases](#test-cases). -The core MUST own exactly enough arithmetic to police this and no more. In particular, pot -growth MUST be bounded by the core independently of policy's accounting: policy may *assert* -an accrual, but the core MUST reject any accrual that would breach the inequality above. -This matters because unbacked-batch creation (`copyBatch`) is precisely a breach of it, and -under C2.2 no policy — honest, buggy, or malicious — can reproduce that behaviour. +The core MUST own exactly enough arithmetic to police this and no more. Unbacked-batch +creation (`copyBatch`) is precisely a breach of the inequality above, and under C2.2 no +policy — honest, buggy, or malicious — can reproduce it. **C2.3 — One-way calls.** Calls MUST go policy → core only. A core MUST NOT call, delegate to, or read from its policy, and MUST NOT expose callbacks or hooks. Core correctness MUST @@ -286,18 +272,18 @@ rate-limited by the core: | Primitive | Bound | |---|---| -| `claimPot()` | at most `MAX_POT_FRACTION_PER_ROUND` of `pot` per `ROUND_LENGTH` window | -| `slash(node, amount)` | at most `MAX_SLASH_PER_EPOCH` per node and in aggregate per epoch | +| `claimPot(amount)` | at most `MAX_POT_FRACTION_PER_WINDOW` of `pot` per `CLAIM_WINDOW` blocks | +| `slash(account, amount)` | at most `MAX_SLASH_PER_WINDOW` in aggregate per `SLASH_WINDOW` blocks | | `setPrice(price)` | `price <= MAX_PRICE`, and the step from `lastPrice` at most `MAX_PRICE_CHANGE_PER_UPDATE` | -| pot accrual | not a policy primitive at all — see C4 | -Suggested initial values are given in [Open questions](#open-questions); they are -parameters of the deployment, immutable in the core once set. The purpose of the bounds is -not to make theft impossible in the limit — it is to make it *slow and visible*, so that the -exit in C2.6 has a usable window. +The windows are core-owned block counts, not the redistribution game's round length, which +is per-branch and replaceable (F1). These are deployment parameters, immutable in the core +once set; their values are an open question. The bounds are not meant to make theft +impossible in the limit — they make it *slow and visible*, so the exit in C2.6 has a usable +window. -**C2.5 — Timelocked policy pointer, enforced by the core.** A core MAY allow its policy -pointer to change, and if it does: +**C2.5 — Timelocked pointers, enforced by the core.** A core MAY allow its policy pointer +or its redistributor pointer to change, and if it does: - the change MUST be proposed and then executed no earlier than `POLICY_TIMELOCK` blocks later, with both proposal and execution emitting events; @@ -305,24 +291,23 @@ pointer to change, and if it does: that a role could replace; - `POLICY_TIMELOCK` MUST be immutable. -To be precise about what this is: a core with a timelocked policy pointer **has a privileged -operation**, and is not literally admin-free. The claim is narrower and checkable — *no -privileged operation can move a user's deposit, and every privileged operation is announced -in advance with a guaranteed exit window*. Descriptions of this design as "no admins" should -be corrected to that formulation. +A core with a timelocked pointer **has a privileged operation** and is not admin-free. The +claim is narrower and checkable: *no privileged operation can move a user's deposit, and +every privileged operation is announced in advance with a guaranteed exit window*. **C2.6 — Permissionless exit.** Each core MUST provide an exit that: - any principal can call for their own funds, with no role check; - has no pause modifier and cannot be disabled by any role; - does not route through any replaceable contract; -- ignores policy-supplied state (commitments, freezes, height) when computing the exit - amount, using only core-recorded claims. +- ignores any lock set by policy when computing the exit amount, using only core-recorded + claims. Concretely: `StakingCore.exit()` returns the caller's recorded deposit, and `PostageAccounting.refundBatch(batchId)` returns the batch's remaining balance to its owner. -`StakingCore.exit()` SHOULD be subject to an `EXIT_DELAY` (a fixed unbonding period, not a -role-gated pause) so that it cannot be used to dodge in-flight slashing. +`exit()` MUST be preceded by `requestExit()` and callable `EXIT_DELAY` blocks later — a +fixed unbonding period, not a role-gated pause — so it cannot be used to dodge in-flight +slashing. The postage exit needs an economic guard, because a batch owner could otherwise top up, upload, and immediately refund, obtaining storage for free. `refundBatch` SHOULD forfeit a @@ -333,8 +318,8 @@ This is an economic parameter, not a security one, and is listed as an open ques takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin is worse than an upgradeable contract. Therefore: -- Cores hold balances, ownership, monotone accumulators, the outpayment accumulator, and - the conservation check. Nothing else. +- Cores hold balances, ownership, the outpayment accumulator, the pointers and bounds their + own invariants need, and the conservation check. - Everything with interesting edge cases — the expiry ordering structure, batch selection, depth and bucket rules, effective-stake curves, commitment maths — lives in policy, where it can be fixed. @@ -342,12 +327,12 @@ worse than an upgradeable contract. Therefore: deployment (see [Test cases](#test-cases)). The outpayment accumulator is the one piece of pricing arithmetic that cannot live in the -replaceable half, and the reason is worth stating precisely. A batch's `normalisedBalance` -is denominated *in the accumulator of the contract that issued it*: +replaceable half. A batch's `normalisedBalance` is denominated *in the accumulator of the +contract that issued it*: ``` currentTotalOutPayment() = totalOutPayment + lastPrice * (block.number - lastUpdatedBlock) -remainingBalance(id) = normalisedBalance[id] - currentTotalOutPayment() +remainingBalance(id) = max(0, normalisedBalance[id] - currentTotalOutPayment()) ``` A fresh contract starts the accumulator at zero, so every balance must be *rebased*, not @@ -361,7 +346,7 @@ The cost of putting it in the core is that **the outpayment model itself is froz per-block accrual against a per-chunk normalised balance. Moving to a different model — non-linear pricing, per-neighbourhood pricing, a different unit of account — is not a policy change and would still require a migration. This is the largest single thing the proposal -gives up, and it is deliberate. +gives up. #### C3. `StakingCore` interface @@ -385,29 +370,27 @@ interface IStakingCore { // ---- policy, bounded (C2.4) ---- /// @notice Reduce a deposit. Burnt in place; never transferred out. - /// Reverts if per-node or per-epoch slash caps are exceeded. - function slash(address node, uint256 amount) external; + /// Reverts if the aggregate slash cap for the window is exceeded. + function slash(address account, uint256 amount) external; + // ---- policy, unbounded but exit-safe ---- /// @notice Prevent withdraw() (but never exit()) for `until`. - function lock(address node, uint64 until) external; + function lock(address account, uint64 until) external; // ---- views ---- - function depositOf(address node) external view returns (uint256); + function depositOf(address account) external view returns (uint256); function totalDeposited() external view returns (uint256); } ``` `StakingCore` MUST NOT store overlays, heights, committed stake, or effective stake, and MUST NOT read `PriceOracle`. Those are per-branch, consensus-critical values, and belong in -`StakingPolicy` for a reason that matters at fork time: **overlay derivation is bound to the -wire protocol** (it mixes `NetworkId`), so it is exactly the kind of value that should be -redeployed with a fork, while deposits are exactly the kind that should not. +`StakingPolicy`. Overlay derivation is network-scoped — it mixes `NetworkId` — so it should +be redeployed with a fork, while deposits should not. `StakingPolicy` SHOULD accept an immutable `predecessor` address and lazily inherit overlay -and height from it on first use, so that a fork requires no operator transaction at all. -Note that `Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before -participation; inheriting predecessor state avoids re-triggering that delay, whereas a -fresh declaration would cost operators roughly two rounds (~25 minutes at +and height from it on first use, so that a fork requires no operator transaction and no +eligibility delay. A fresh declaration would instead cost roughly two rounds (~25 minutes at `ROUND_LENGTH = 152` on Gnosis). **C3.1 — Eligibility clock.** `StakingPolicy` MUST compute participation eligibility from @@ -415,25 +398,26 @@ fresh declaration would cost operators roughly two rounds (~25 minutes at transaction an operator MAY send in advance of a deposit or a cutover. `Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before a node may -participate. Without a pre-registration clock, any event that causes many operators to -establish a stake record at similar times produces a **rolling participation trough**: for -the duration of the spread, effective participation is a fraction of normal, and with few -participants a single dissenter's chance of being leader rises sharply — which is the -v2.8.0 failure mode, self-inflicted. Note that staggering such an event to avoid a gas -spike makes the trough *worse*, not better, by lengthening it. Pre-registration lets the -settling period elapse before the event, so no operator waits at cutover and no trough is -created. +participate. Without a pre-registration clock, any event that makes many operators +establish a stake record at similar times produces a **rolling participation trough**. +While the spread lasts, effective participation is a fraction of normal, and with few +participants a single dissenter's chance of being leader rises sharply — the v2.8.0 failure +mode, self-inflicted. Staggering the event to avoid a gas spike lengthens the trough rather +than fixing it. Pre-registration lets the settling period elapse beforehand, so no operator +waits at cutover. **C3.2 — Accounts and nodes.** `StakingCore` MUST record deposits per *account* and MUST NOT assume a one-to-one relationship between an account and a node identity. Mapping an account to one or more node overlays is `StakingPolicy`'s responsibility, since overlay derivation is already policy-side. -This is close to free once overlay lives in policy, and it has three consequences worth -naming: fleet operations become proportional to accounts rather than nodes, so a large -operator can fund or exit an entire fleet in one transaction; withdrawal authority is -separated from the node's operational signer, so a compromised node key cannot move funds; -and the cost of the one final stake migration falls sharply. +This is close to free once overlay lives in policy, and it has three consequences: + +- fleet operations become proportional to accounts rather than nodes, so a large operator + can fund or exit an entire fleet in one transaction; +- withdrawal authority is separated from the node's operational signer, so a compromised + node key cannot move funds; +- the cost of the one final stake migration falls sharply. It introduces one question the policy MUST answer explicitly: if several nodes are backed by one account, a slash earned by one node reduces the stake backing the others. Acceptable @@ -452,13 +436,13 @@ interface IPostageAccounting { /// @notice Add funds to an existing batch. Owner unchanged. function topUp(bytes32 batchId, uint256 amount) external; - /// @notice Permissionless exit (C2.6). Pays batches[batchId].owner only. + /// @notice Owner-only exit, no role check (C2.6). Pays ownerOf(batchId). /// May forfeit a fixed fraction to the pot (see C2.6). function refundBatch(bytes32 batchId) external; - /// @notice Credit the pot with the residual value of expired batches. + /// @notice Credit the pot for batches that have reached zero balance. /// Permissionless. For each id the core verifies remainingBalance(id) == 0 - /// for itself; ordering hints from policy are not trusted. + /// for itself; ordering hints are not trusted. function expire(bytes32[] calldata batchIds) external; // ---- policy, bounded (C2.4) ---- @@ -488,40 +472,34 @@ There is no `withdraw(address)`. The redistributor pointer is singleton by const rather than by role hygiene, which is the direct fix for the v0.9.3 double-redistributor race. -`claimPot` takes an amount but not a destination, and there is no `accrue` primitive: pot -growth is not something policy can assert. The core derives every batch's remaining balance -from its own accumulator, and `expire` is permissionless and self-verifying — a caller -supplies candidate batch ids, and the core credits the pot only for ids it independently -confirms have reached zero. Policy therefore has no pot-accrual authority whatsoever, which -is a strict reduction in policy authority relative to the first draft of this SWIP. +`claimPot` takes an amount but not a destination, and there is no `accrue` primitive: the +core derives every batch's remaining balance from its own accumulator. Policy therefore has +no pot-accrual authority. Batch *identity and semantics* — bucket depth validity, immutability flags, minimum initial balance, depth-increase rules — live in `PostagePolicy`. The expiry *ordering* structure (today `HitchensOrderStatisticsTreeLib`) also lives in policy: it is a search index over -core state, rebuildable from events, and it is the single most edge-case-heavy component in -the current contract, so it belongs in the half that can be fixed. Ordering is a hint; -`expire` verifies. +core state, rebuildable from events, and the single most edge-case-heavy component in the +current contract, so it belongs in the half that can be fixed. Ordering is only a hint — +`expire` verifies each id against the core's own balance. #### C5. Residual trust after Part 1 -Stated explicitly so it can be argued with: - | Capability | Today | After Part 1 | |---|---|---| | Steal all staked BZZ | No (burn only) | No | -| Burn all staked BZZ | Yes (redistributor role) | No — capped per epoch (C2.4) | +| Burn all staked BZZ | Yes (redistributor role) | No — capped per window (C2.4) | | Steal the entire pot in one call | Yes (`withdraw(beneficiary)`) | No — no such primitive (C2.1) | | Drain the pot over time | Yes | Bounded, visible, timelocked (C2.4, C2.5) | | Create unbacked batch state | Yes (`copyBatch`) | No (C2.2) | | Misdirect *future* rewards | Yes | Yes, after `POLICY_TIMELOCK`, announced | -| Close the user escape hatch | Yes (`PAUSER_ROLE`) | No (C2.6) | +| Close the user escape hatch | Yes (`DEFAULT_ADMIN_ROLE`) | No (C2.6) | -The row that does not go away is the last-but-one: whoever controls policy can still bias -who wins the pot, which is an indirect claim on future revenue. **Custody separation protects -deposits, not rewards.** Bounding reward direction further would require freezing -redistribution verification itself, which conflicts directly with F1's requirement that -`Redistribution` be redeployed per fork. The trade is deliberate, and named here rather than -left implicit. +Two capabilities survive: draining the pot slowly within the C2.4 bounds, and misdirecting +future rewards after `POLICY_TIMELOCK`. **Custody separation protects deposits, not +rewards.** Bounding reward direction further would require freezing redistribution +verification itself, which conflicts with F1's requirement that `Redistribution` be +redeployed per fork. ### Part 2 — Fork migration @@ -533,9 +511,8 @@ which the old branch is intended to be wound down. Every breaking change to the protocol to date has been a fork-migration (*Forking Swarm*). A **breaking wire release** is a client release whose peer-negotiated protocol version -differs from its predecessor's, such that a version mismatch causes disconnection. Because -mismatch causes disconnection, a breaking wire release always produces at least two disjoint -p2p networks. +differs from its predecessor's, so that mismatched peers disconnect. A breaking wire release +therefore always produces at least two disjoint p2p networks. #### F1. A new `Redistribution` per breaking wire release @@ -602,12 +579,11 @@ reveal window and may be frozen for a phase violation they did not cause. Therefore: -- The outgoing `Redistribution` MUST stop accepting new commits from the start of the round - preceding `activationBlock`, so the final round drains through reveal and claim normally. - The incoming `Redistribution` MUST accept commits from `activationBlock` onward. - The authority change on `PostageAccounting` (F4) MUST execute at `activationBlock`. -"No gap" and "no orphaned round" are distinct properties. This SWIP requires both. +A gap in redistributor coverage and an orphaned round are distinct failures; the rules +above prevent both. #### F4. Exactly one redistributor, by construction @@ -617,28 +593,26 @@ pointer changes only through `proposeRedistributor` / `executeRedistributor` und pointer. This replaces `REDISTRIBUTOR_ROLE` as an `AccessControl` role, under which multiple holders -are representable and were in fact simultaneously authorised in 2025. Singleton-ness becomes -a property of the type, not of operational discipline. +are representable and were in fact simultaneously authorised in 2025. Here it is a property +of the type, not of operational discipline. Cutover execution is therefore: `executeRedistributor()` on `PostageAccounting`, plus the policy pointer update if policy changed, in a single transaction from the governing -multisig. It MUST be a single transaction. "Atomic" is not satisfied by several transactions -sent close together — the v0.9.3 incident is what several transactions close together looks -like. +multisig. It MUST be a single transaction: "atomic" is not satisfied by several transactions +sent close together, as the v0.9.3 incident shows. #### F5. Old-branch wind-down Immediately zeroing rewards on the old branch is correct for incentive alignment and wrong -for data availability: old-branch data remains retrievable only while old-branch nodes stay -online, which is precisely when they have stopped being paid. +for data availability: old-branch data stays retrievable only while old-branch nodes stay +online, and zeroing rewards is what takes them offline. Where a fork requires user-side action with a tail — a wire-protocol change, since batches themselves now carry across — the schedule SHOULD include a wind-down window during which the old `Redistribution` continues to pay at a reduced rate, decaying to zero. This is a -deliberate exception to "no overlap", and it is safe under F4 in a way it was not in 2025: -the two redistributors are authorised against *different* postage cores only if a postage -migration is happening at all, and in the normal case there is one core, one pointer, and -the wind-down is paid from a fixed, pre-funded allocation rather than from the live pot. +payment overlap, not an authority overlap: the retired `Redistribution` is never the core's +authorised pointer during the wind-down, so F4 is not relaxed. How the window is funded is +an open question. The residual pot in any retired core MUST have a defined destination. This SWIP does not fix one; see [Open questions](#open-questions). @@ -653,7 +627,7 @@ A conforming client: 3. MUST switch the `Redistribution` address it uses at `activationBlock`, not when the operator restarts. 4. MUST NOT send any fund-moving transaction as an automated consequence of a chain signal. - Under Part 1 no such transaction is required at cutover, which is the point. + Under Part 1 no such transaction is required at cutover. 5. SHOULD expose the pending cutover in its status API and log a warning when it is running a version whose cutover has passed. @@ -669,9 +643,9 @@ dual-mode code is required, because a non-upgraded node is on the other branch a be paid from this branch's pot. **Type B — contract-only.** The wire protocol is unchanged: a `Redistribution` bugfix, a -policy parameter change, a new `PostagePolicy`. Continuity is expected — operators who have -not restarted MUST keep earning across `activationBlock` — so the client MUST carry both -contract bindings and switch at `activationBlock`. The legacy binding MAY be removed in the +policy parameter change, a new `PostagePolicy`. Continuity is expected — operators running a +release that carries both bindings MUST keep earning across `activationBlock` — so the +client MUST carry both and switch at `activationBlock`. The legacy binding MAY be removed in the first release after the cutover. **F7.1 — Consensus-path rule.** A cutover that would require a runtime branch in @@ -683,9 +657,7 @@ The reason is that a dual-mode sampler is itself a source of dissent: two nodes disagree about which mode they are in produce divergent reserve commitments, which is precisely the failure mode F1 exists to prevent. F7.1 confines Type B's dual-mode surface to contract call sites, where it is cheap, and pushes anything deeper into Type A, where the -network partition already does the separating. It converts "supporting two ABIs is -unbounded maintenance" from an objection into a design constraint that stops the expensive -case from arising. +network partition already does the separating. Under Part 1 the frozen cores never acquire a second ABI, so deposits, withdrawals and balance reads never branch in either type. Only policy and `Redistribution` bindings do. @@ -704,31 +676,23 @@ wire-only forks keep commingling incentives. Together: ## Rationale -**Why not proxies over the fund-holding contracts.** Covered in [Motivation](#motivation). -Briefly: a proxy over a vault is a custody grant; per-call registry verification is an -availability risk; and if the vault cannot be swapped, the anti-swap machinery is -unnecessary. - **Why not "always full redeploy".** *Forking Swarm*'s proposal is coherent but expensive, and its cost is not bounded in the document. It requires a batch migration at every breaking wire release, and it leaves batch migration undesigned. It also relies on an -incentive asymmetry that does not hold: operators follow money and will migrate stake to -keep earning, but a user who fails to migrate a batch loses availability they may not notice -until they need the data. Operators follow money; users follow nothing. Part 1 removes the -requirement rather than solving the coordination problem. +incentive asymmetry that does not hold: operators will migrate stake to keep earning, but a +user who fails to migrate a batch loses availability they may not notice until they need the +data. Part 1 removes the requirement rather than solving the coordination problem. **Why the registry survives as a cutover signal.** The question raised on [`storage-incentives#310`][pr310] — who benefits from an on-chain registry, and how does it -compare to publishing under ENS or on GitHub — has a straight answer: for *security* it adds -nothing, because the trust root is the client release process either way. For *coordination* -it adds something real, because it lets every client switch at the same block regardless of -when its operator restarted. F2 keeps the coordination and F2.1 removes the security -temptation. +compare to publishing under ENS or on GitHub — has a straight answer. For *security* it adds +nothing: the trust root is the client release process either way. For *coordination* it adds +something real: it lets every client switch at the same block regardless of when its +operator restarted. F2 keeps the coordination and F2.1 removes the security temptation. -**Why staking first.** It is the case where the target property is cleanest (funds already -only flow to `msg.sender`), it is the case where the objection to upgradeability was -strongest, and demonstrating a frozen core there earns the standing to freeze the postage -ledger afterwards. +**Why staking first.** Demonstrating a frozen core on the easy case — where funds already +only flow to `msg.sender`, and where the objection to upgradeability was strongest — earns +the standing to freeze the postage ledger afterwards. **Why bounds rather than prohibitions.** A design in which policy has no authority at all over funds cannot slash, cannot pay winners, and is therefore not an incentive system. The @@ -739,9 +703,8 @@ usable exit. C2.4 through C2.6 are that goal made concrete. - *Immutable policy pointer in the core.* Strictly stronger, but then changing policy means a new core, which reintroduces migration and defeats the purpose. -- *External timelock contract owning the pointer.* Weaker than C2.5, because whoever can - replace the timelock's owner can shorten the window. Self-enforcement in the core with an - immutable constant is the point. +- *External timelock contract owning the pointer.* Weaker than C2.5: whoever can replace the + timelock's owner can shorten the window. - *Governance vote on policy changes.* Orthogonal and compatible; this SWIP specifies the contract-level constraints that hold regardless of how the governing address is constituted. @@ -758,17 +721,13 @@ also, by design, intended to be the **last** such change that moves user funds. 1. *Final stake migration.* Operators move deposits from `StakeRegistry` to `StakingCore`. This is the last time. It SHOULD be run under the F2/F3 protocol, and — unlike 2025 — the old registry MUST be paused at `activationBlock` rather than at an unrelated later date, - so that no window exists in which an upgraded operator cannot earn. + so that the interval between the client release and the pause is zero. 2. *Final batch migration.* Batches move from `PostageStamp` to `PostageAccounting`. This is - the last time. It is the harder of the two and SHOULD be user-driven wherever possible; if - an admin-assisted path is used for the tail, that path MUST be time-limited by an - immutable deadline in `PostageAccounting` after which it cannot be called, and MUST require - a matching BZZ transfer so that C2.2 holds during migration. That last requirement is the - specific defect in today's `copyBatch`. - -**Retirement of `copyBatch`.** `PostageAccounting` MUST NOT include an unbacked -batch-creation function. After migration, `copyBatch` and `copyBatchBulk` cease to exist as -a capability. + the last time. It is the harder of the two and SHOULD be user-driven wherever possible; + any assisted path for the tail MUST go through the ordinary `fund()` call, so migration is + deposit-matched and needs no privileged function in the core. `PostageAccounting` MUST NOT + include an unbacked batch-creation function, which is the specific defect in today's + `copyBatch`. **Client ABI.** Clients must learn a two-contract layout per subsystem: reads that are consensus-critical (overlay, effective stake, batch validity) come from policy; balances and @@ -788,28 +747,28 @@ contract tests. The following are mandatory before any core deployment. **Invariant tests (must hold after every call, under all orderings).** - `sum(recorded claims) + pot <= token.balanceOf(core)` (C2.2). -- `totalDeposited - totalWithdrawn - totalSlashed == token.balanceOf(StakingCore)`. +- `totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore)` (slashed BZZ is burnt + in place, so the balance exceeds the claims). - No execution path transfers to an address not derived from core state (C2.1) — enforced by a static check over the core's bytecode as well as by tests. - No core function reaches an external call into the policy address (C2.3). **Adversarial-policy tests.** Instantiate each core with a deliberately malicious policy -that attempts, at minimum: draining the pot in one call; slashing every node to zero; +that attempts, at minimum: draining the pot in one call; slashing every account to zero; claiming more than the per-round cap; accruing pot beyond conservation; blocking a user's exit; setting a price above `MAX_PRICE`. Each MUST revert, and `exit()` MUST succeed throughout. **Exit tests.** `exit()` and `refundBatch()` MUST succeed while the policy is malicious, while the policy address is zero, while a policy change is pending in the timelock, and — for -`StakingCore` — while the node is locked or frozen by policy. +`StakingCore` — while the account is locked by policy. **Timelock tests.** A policy or redistributor change MUST NOT take effect before `POLICY_TIMELOCK`; the pending change MUST be readable throughout the window. **Cutover tests.** A cutover at a round boundary MUST NOT orphan a committed node (F3); a -cutover proposed off-boundary MUST revert; the old redistributor MUST reject commits in the -final round and MUST still accept reveals and claims for the round already committed; a -`manifest` mismatch MUST cause client hard-failure. +cutover proposed off-boundary MUST revert; a `manifest` mismatch MUST cause client +hard-failure. **Accumulator continuity tests.** A policy replacement MUST NOT change `currentTotalOutPayment()`, `normalisedBalanceOf()` or `remainingBalance()` for any batch. @@ -843,19 +802,19 @@ Staged so that each stage is independently valuable and independently revertible | 3 | `Cutover` contract and client support (F2, F3, F6, F7). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | | 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | | 5 | `PostageAccounting` + `PostagePolicy`. Final batch migration. `copyBatch` retired | 4 | -| 6 | `POLICY_TIMELOCK` extended; governing multisig scope reduced to policy pointers only | 5 | +| 6 | Governing multisig scope reduced to policy pointers only | 5 | Stage 1 addresses measured harm and is the immediate next upgrade. Stage 2 is a process decision available today at no cost. Stages 4 and 5 are where the custody property lands. -After stage 5, surgical redeployment and the absence of custody admin powers coexist — the -two things currently treated as mutually exclusive. +After stage 5, surgical redeployment and the absence of admin power over deposits coexist — +the two things currently treated as mutually exclusive. ## Open questions 1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` - (suggested: aligned with the current freeze horizon), `MAX_SLASH_PER_EPOCH`, - `MAX_POT_FRACTION_PER_ROUND`, `MAX_PRICE`, `MAX_PRICE_CHANGE_PER_UPDATE`, - `CUTOVER_NOTICE`. These are immutable once deployed and so need their own analysis. + (suggested: aligned with the current freeze horizon), `MAX_SLASH_PER_WINDOW`, + `SLASH_WINDOW`, `MAX_POT_FRACTION_PER_WINDOW`, `CLAIM_WINDOW`, `MAX_PRICE`, + `MAX_PRICE_CHANGE_PER_UPDATE`, `CUTOVER_NOTICE`. These are immutable once deployed and so need their own analysis. 2. **Postage exit economics.** What forfeit fraction or minimum batch age makes `refundBatch` non-abusable without making it useless as an escape hatch? 3. **Stranded pot.** Where does the residual pot in a retired core go, given that by @@ -865,7 +824,8 @@ two things currently treated as mutually exclusive. 5. **Tail of the final batch migration.** Is a deadline-limited, deposit-matched admin-assisted path acceptable, or must the final migration be fully user-driven even at the cost of abandoning some batches? -6. **Multi-client discipline.** F1–F3 assume every client implements cutover identically. +6. **Multi-client discipline.** F2, F3 and F6 assume every client implements cutover + identically. What is the conformance mechanism if a second client exists? 7. **Shared-account slashing.** Under C3.2, how is a slash apportioned when one account backs several nodes — per-node sub-allocations, or a coverage requirement on the @@ -879,8 +839,7 @@ two things currently treated as mutually exclusive. - [`ethersphere/storage-incentives#310`][pr310] — Versioned Registry Router + Upgradeable Proxies for All Core Contracts, and the review discussion that motivated this SWIP. - *Forking Swarm: A migration guide* — Andrew Macpherson, Shtuka Research (presentation, - 2026). Source of the fork framing, the v2.8.0 dissent measurements, and the - v0.9.3/v0.9.4 case study. Not yet published at a stable URL; to be linked or mirrored + 2026). Not yet published at a stable URL; to be linked or mirrored under `SWIPs/assets/swip-67/` with the author's consent. - Deployed contracts referenced throughout: `src/PostageStamp.sol`, `src/Staking.sol`, `src/Redistribution.sol` in `ethersphere/storage-incentives`. @@ -898,22 +857,13 @@ strict increase in attack surface, from burn to steal; and the observation that between a client release and the pausing of the old stake registry is dead time for upgraded operators. -Review of the first draft materially changed Part 1. Mark Bliss identified that a batch's -remaining balance and expiry are computed against the issuing contract's outpayment -accumulator, so switching contracts requires derived state to be rebased rather than -re-pointed — which establishes that the accumulator cannot live in the replaceable half -(C2.7, C4), and that a policy-side accumulator would have been strictly worse than the -status quo. The same review supplied the partial-batch-set data-loss window, the -observation that a half-completed migration and a precompiled fork height are in direct -contradiction, and the dual-ABI maintenance argument that F7.1 answers. (GitHub handle to -be added.) - -Note that this SWIP departs from *Forking Swarm* on one conclusion: that document argues -that phasing out admin powers makes surgical redeployment impossible and therefore requires -full-suite redeployment with batch and stake migration at every fork. Part 1 argues the -coupling that makes this true is removable, and Part 2 is adapted accordingly. Co-authorship -is listed on the strength of the derived material; @awmacpherson should feel free to ask for -his name to be removed if he does not want to be associated with that departure. +Review of the first draft materially changed Part 1. Mark Bliss established that the +outpayment accumulator cannot live in the replaceable half, and why (C2.7), and supplied the +dual-ABI maintenance argument that F7.1 answers. (GitHub handle to be added.) + +This SWIP departs from *Forking Swarm* on one conclusion, set out in +[Motivation](#motivation). Co-authorship is listed on the strength of the derived material; +@awmacpherson should feel free to ask for their name to be removed. ## Copyright From 33d2a9ec0cff64804682b3df7078662880e53c6b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 00:18:47 +0200 Subject: [PATCH 06/24] =?UTF-8?q?swip-67:=20close=20the=20accounting=20hol?= =?UTF-8?q?es=20=E2=80=94=20core=20batch=20sizes,=20genesis-backed=20migra?= =?UTF-8?q?tion,=20windowed=20cutover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design changes, each forced by checking the spec against the deployed contracts: - Batch migration: PostageStamp has no path that releases an unexpired deposit (withdraw moves only the pot), so "assisted migration through ordinary fund()" was unimplementable. Replaced with a treasury-matched genesis: batch state seeded at deployment, matching BZZ transferred in, genesis sealed in the same ceremony, treasury reimbursed from the old pot as seeded batches expire — the final, announced use of withdraw(beneficiary). No unbacked creation path ever exists. - Core accounting: the C4 interface had no batch size, so the core could not compute normalised balances, conservation, or pot accrual. fund() now records depth, resize() replaces increaseDepth, and validChunkCount / lastExpiryBalance are core aggregates with the accrual identity from expireLimited written out normatively. Consequence: the expiry ordering moves back into the core — live-chunk accrual is only sound when the core can prove no expired batch is still counted, which requires knowing the minimum normalised balance. C2.7 names this the second-largest frozen-core risk instead of calling the tree a rebuildable policy-side index. - fund() call topology specified: creation is policy-gated (admissibility), exits and expiry are direct on the core; new ids bind to (originator, nonce) so announced ids cannot be front-run; seeded ids exist only before genesis seal. - StakingCore records firstDepositBlock, making the C3.1 eligibility clock computable. C3.2 resolved with a coverage requirement instead of an open question. - F3: exact-block multisig execution replaced by an execution window [activationBlock, activationBlock + EXECUTION_WINDOW), aligned to the outgoing game's round length; ROUND_LENGTH changes are Type A. C2.5 pointers are cancellable. F2.3 added: Cutover governance is liveness-only. - F5: wind-down must be pre-funded before cutover; a retired Redistribution never regains pot access. Resolves the F4 contradiction and the stranded-pot question. - Honesty fixes: C1 table no longer says "no admin"; Redistribution acknowledged as transient pot custodian on the claimPot path; the C2.4 pot bound described as capping acceleration, not stopping outflow below the honest rate; exit() named as withdrawable stake with EXIT_DELAY >= the freeze horizon; refundBatch named as a change to the storage promise whose introduction is a Type A cutover. Open questions cut from eight to five; resolved ones (wind-down funding, migration tail, shared-account slashing, stranded pot) folded into the spec. Concision pass throughout. --- SWIPs/swip-67.md | 878 +++++++++++++++++++++++++---------------------- 1 file changed, 461 insertions(+), 417 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 7a5408f9..09c184fc 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -15,13 +15,12 @@ funds, and specifies the fork-migration protocol that replacement runs under. -- ## Contents - [Simple Summary](#simple-summary) · [Abstract](#abstract) -- [Motivation](#motivation) — why the security and migration problems are one problem, and - where the custody surface is in today's deployed code +- [Motivation](#motivation) - [Specification](#specification) - [Key normative requirements at a glance](#key-normative-requirements-at-a-glance) - [Part 1 — Custody separation](#part-1--custody-separation) (C1–C5) - [Part 2 — Fork migration](#part-2--fork-migration) (F0–F8) -- [Rationale](#rationale) — design choices and rejected alternatives +- [Rationale](#rationale) - [Backwards compatibility](#backwards-compatibility) — the two final migrations - [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) - [References](#references) · [Acknowledgements](#acknowledgements) @@ -38,16 +37,13 @@ place. That single fact causes both of our recurring problems: money — a fund movement for every user and every operator, every time the rules change. This SWIP splits each contract in two. A **core** holds the money, has no admin power over -it, is never upgraded, and enforces its own accounting invariants. A **policy** holds the rules, is -freely replaceable, and can never name a payment destination. It then specifies the -**fork-migration protocol** — how a new policy and a new Redistribution contract are cut -over atomically at a round boundary, so that a protocol upgrade stops being a fund -movement at all. +it, is never upgraded, and enforces its own accounting invariants. A **policy** holds the +rules, is freely replaceable, and can never name a payment destination. It then specifies +the **fork-migration protocol** — how a new policy and a new Redistribution contract are +cut over at a round boundary, so that a protocol upgrade stops being a fund movement. ## Abstract -We specify two coupled changes to the storage-incentive contract suite. - **Part 1 — Custody separation.** `PostageStamp` and `StakeRegistry` are each split into a frozen custody core (`PostageAccounting`, `StakingCore`) and a replaceable policy contract (`PostagePolicy`, `StakingPolicy`). @@ -63,9 +59,9 @@ submission, overlay derivation, commitment and effective-stake maths, and slashi new `Redistribution` deployment, even when its code is unchanged, so that the two branches of the resulting network fork do not play the same redistribution game. Cutover is signalled on chain by a `Cutover` contract that publishes *timing only*; contract addresses -are carried in the client binary. Cutover MUST land on a round boundary, so no node is -mid-game when authority changes. `PostageAccounting` enforces at most one authorised -redistributor at any block. +are carried in the client binary. Cutover MUST land on a round boundary and execute within +a bounded window. `PostageAccounting` enforces at most one authorised redistributor at any +block. Together the parts remove admin custody of deposits, bound admin influence over future rewards, and reduce a protocol upgrade from "everyone moves their money" to "clients point @@ -75,56 +71,58 @@ at a new policy address". ### The two problems are one problem -Two threads have been running in parallel: an upgradeability thread (see -[`storage-incentives#310`][pr310]), and a migration thread about how we roll out new -network versions (see *Forking Swarm*). They are the same problem seen from two sides. - -Because state and logic live in the same contract, replacing logic means replacing state. -Replacing state means a migration. Avoiding the migration means giving an admin a shortcut -over state — which is a power over funds. So we oscillate between two bad options: +Two threads have been running in parallel: an upgradeability thread +([`storage-incentives#310`][pr310]) and a migration thread (*Forking Swarm*). They are the +same problem seen from two sides. Because state and logic live in the same contract, +replacing logic means replacing state; replacing state means a migration; avoiding the +migration means giving an admin a shortcut over state — which is a power over funds. So we +oscillate between two bad options: 1. **Use the admin shortcut.** Cheap, but the admin can steal the pot and burn all stake. -2. **Do a full redeployment and migrate everything.** Rug-resistant, but it turns every - logic change into a fund movement for every user and every operator, and no batch - migration has ever been completed without admin-driven cloning. +2. **Do a full redeployment and migrate everything.** Rug-resistant, but every logic change + becomes a fund movement for every user and operator, and no batch migration has ever + been completed without admin-driven cloning. -The conclusion drawn in *Forking Swarm* — that phasing out admin powers makes -surgical redeployment impossible, so every upgrade must become a full-suite redeployment -with batch and stake migration — is true of the *current* architecture but is not -architecturally necessary. It is a consequence of the coupling, not of the threat model. -Break the coupling and both options improve at once. +*Forking Swarm* concludes that phasing out admin powers makes surgical redeployment +impossible, so every upgrade must become a full-suite redeployment with batch and stake +migration. That is true of the *current* architecture but is a consequence of the +coupling, not of the threat model. Break the coupling and both options improve at once. ### Where the custody surface is, in code -The following are properties of the deployed contracts as of writing, not hypotheticals. +These are properties of the deployed contracts as of writing. **`PostageStamp.withdraw(address beneficiary)`** is gated on `REDISTRIBUTOR_ROLE` and transfers the whole of `totalPot()` to a caller-supplied address. -**`REDISTRIBUTOR_ROLE` is an OpenZeppelin `AccessControl` role**, so any number of addresses -can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. Two were in fact -authorised at once during the v0.9.3/v0.9.4 rollout (see below). +**`REDISTRIBUTOR_ROLE` is an OpenZeppelin `AccessControl` role**, so any number of +addresses can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. Two were in +fact authorised at once during the v0.9.3/v0.9.4 rollout (see below). **`PostageStamp.copyBatch` and `copyBatchBulk`** are gated on `DEFAULT_ADMIN_ROLE` and create batch state — owner, depth, `normalisedBalance` — while incrementing `validChunkCount`, **without transferring any BZZ into the contract**. `totalPot()` returns `min(pot, balance)`, so unbacked state cannot directly over-transfer; but unbacked chunks -accrue pot at the same rate as paid ones, so the admin can accelerate pot accrual against -the deposits of real batch owners. These functions exist to facilitate exactly the batch -migrations this SWIP aims to make unnecessary. +accrue pot at the same rate as paid ones, against the deposits of real batch owners. + +**`PostageStamp` has no path that returns an unexpired batch deposit to anyone.** The only +outflow is `withdraw`, and it moves the pot, never batch balances. Remaining prepaid +storage is locked until it expires into the pot. This is why every batch migration to date +has used `copyBatch`: the funds for an honest re-purchase cannot be extracted. Any +migration plan that bans unbacked minting must therefore also say where the backing BZZ +comes from — see [Backwards compatibility](#backwards-compatibility). -**`StakeRegistry` is, by contrast, rug-resistant today.** No code path sends BZZ -anywhere except back to `msg.sender` (`withdrawFromStake`, `migrateStake`), and -`slashDeposit` only decrements the record without transferring, so slashed BZZ is burnt in -place rather than stolen. This is the property any change must preserve: making -`StakeRegistry` upgradeable in the ordinary sense would be a strict increase in attack -surface, from "burn" to "steal". +**`StakeRegistry` is, by contrast, rug-resistant today.** No code path sends BZZ anywhere +except back to `msg.sender` (`withdrawFromStake`, `migrateStake`), and `slashDeposit` only +decrements the record, so slashed BZZ is burnt in place rather than stolen. Any change must +preserve this: making `StakeRegistry` upgradeable in the ordinary sense would be a strict +increase in attack surface, from "burn" to "steal". **The existing escape hatch does not survive its own threat model.** `StakeRegistry.migrateStake()` is `whenPaused`, and `pause()` requires `DEFAULT_ADMIN_ROLE` (the contract declares no `PAUSER_ROLE`; the `OnlyPauser()` error name is misleading). In -the scenario the hatch exists for — the admin is the adversary — the hatch stays shut unless -the adversary opens it. +the scenario the hatch exists for — the admin is the adversary — the hatch stays shut +unless the adversary opens it. ### What has gone wrong @@ -142,47 +140,41 @@ From *Forking Swarm*: the old stake registry was scheduled well after the corresponding client release, so operators who had upgraded were unable to earn until it happened. -Neither of these is evidence that migration is inherently slow or expensive. Both are -scheduling failures: overlapping authority that should have been singleton, and a cutover -that was staggered when it should have been atomic. F3 and F4 remove both by construction. - -The structural point worth keeping from *Forking Swarm* is that **the interval between a -new client release and the pausing of the old stake registry is dead time for everyone who -has upgraded**. Its length in any given rollout is a matter of scheduling; the remedy is to -make the interval zero by construction rather than to try to keep it short. +Neither is evidence that migration is inherently slow or expensive. Both are scheduling +failures: overlapping authority that should have been singleton, and a cutover that was +staggered when it should have been atomic. F3 and F4 remove both by construction. The +structural point worth keeping is that **the interval between a client release and the +pausing of the old registry is dead time for everyone who has upgraded**; the remedy is to +make the interval zero by construction. ### Why not put everything behind proxies [`storage-incentives#310`][pr310] proposes upgradeable proxies for all core contracts plus -an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path that -lets a client pin an expected implementation atomically. The objections raised in review of -that approach hold, and this SWIP is the alternative: +an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path. The +objections raised in its review hold, and this SWIP is the alternative: - A proxy over a fund-holding contract hands the proxy admin the ability to steal those funds. - Verifying the registry inside the proxy fallback taxes every user call and introduces a - liveness hazard: a mistaken deprecation or a codehash mismatch reverts *all* user calls, - including withdrawals. That layers an availability risk on top of the custody risk it is - trying to mitigate. + liveness hazard: a mistaken deprecation or codehash mismatch reverts *all* user calls, + including withdrawals. - `pinnedExecute` imposes a permanent selector-collision constraint on every future implementation ABI and adds a second delegatecall path parallel to the fallback. -- The machinery solves "the admin swapped the implementation under me". - If user funds live in a contract that cannot be swapped, that event is no longer a - fund-loss event, and the machinery is not needed. +- The machinery solves "the admin swapped the implementation under me". If user funds live + in a contract that cannot be swapped, that event is no longer a fund-loss event, and the + machinery is not needed. -This SWIP therefore keeps a registry-like contract for the job it is good at — signalling -cutover timing (F2) — and drops the guarded proxy and `pinnedExecute`. See -[Rationale](#rationale) for why the registry is a coordination tool rather than a security -one. +This SWIP keeps a registry-like contract for the job it is good at — signalling cutover +timing (F2) — and drops the guarded proxy and `pinnedExecute`. ### What this SWIP does not claim Custody separation removes the *on-chain* cost of a migration. It does not remove the fork itself. Per-batch bucket counters, stamp validity as seen by nodes, and chunk availability are off-chain, per-branch state, and they still partition on a wire-protocol change exactly -as described in *Forking Swarm*. Batches carry across a fork unchanged under this -proposal; the stamp set still forks. What disappears is the coordination tax that made -forks expensive enough to avoid. +as described in *Forking Swarm*. Batches carry across a fork unchanged under this proposal; +the stamp set still forks. What disappears is the coordination tax that made forks +expensive enough to avoid. ## Specification @@ -195,20 +187,21 @@ RFC 2119. |---|---| | **C1** | Each fund-holding contract splits into a frozen core and a replaceable policy. Cores are never deployed behind a proxy and contain no `delegatecall`. | | **C2.1** | No core function transfers to a caller-supplied address. Every destination derives from core state. | -| **C2.2** | The core enforces `recorded claims + pot <= token balance` itself, incrementally, on every call. | +| **C2.2** | The core enforces token conservation itself, incrementally, on every call, including pot accrual over live chunks. | | **C2.3** | Calls go policy → core only. No callbacks, no core reads of policy, no dependence of core correctness on policy code. | | **C2.4** | The core rate-limits every value-moving primitive policy can trigger. | -| **C2.5** | The policy pointer changes only after a timelock the core enforces with an immutable constant. | +| **C2.5** | Pointers change only after a timelock the core enforces with an immutable constant; a pending change is cancellable, never extendable. | | **C2.6** | Each core offers an exit with no role check, no pause, and no dependence on policy state. | -| **C2.7** | Cores have no upgrade path, so they MUST stay minimal. The outpayment accumulator lives in the core, which freezes the outpayment model. | -| **C3.1** | Participation eligibility counts from `min(depositBlock, preRegistrationBlock)`. | -| **C3.2** | Deposits are recorded per account, not per node. Overlay mapping is policy-side. | -| **C4** | `claimPot` takes no destination; there is no `accrue`; `expire` is permissionless and self-verifying; `setPrice` is bounded. | +| **C2.7** | Cores have no upgrade path, so they MUST stay minimal. The outpayment accumulator, batch sizes, and the expiry ordering live in the core; the outpayment model is thereby frozen. | +| **C3.1** | Participation eligibility counts from `min(firstDepositBlock, preRegistrationBlock)`; the core records `firstDepositBlock`. | +| **C3.2** | Deposits are recorded per account. An account's deposit MUST cover the sum of committed stakes of the nodes it backs. | +| **C4** | Batch creation is policy-gated; ids bind to the originating account; `claimPot` takes no destination; `expire` is permissionless and self-verifying; `setPrice` is bounded. | | **F1** | Every breaking wire release deploys a new `Redistribution`, even if the bytecode is unchanged. | | **F2.1** | The chain signal carries timing. Contract addresses are compiled into the client. | | **F2.2** | Clients determine activation by observing `Cutover` state, never by a height baked into the binary. | -| **F3** | `activationBlock` falls on a round boundary, so no node is mid-game when authority changes. | +| **F3** | `activationBlock` falls on a round boundary of the outgoing game, and authority changes execute within a bounded window from it. | | **F4** | At most one redistributor is authorised at any block, enforced by type rather than by role hygiene. | +| **F5** | Any old-branch wind-down is pre-funded before cutover; a retired `Redistribution` never regains pot access. | | **F6** | Clients read timing from chain, addresses from the binary, and send no fund-moving transaction in response to a chain signal. | | **F7.1** | A cutover needing a runtime branch in consensus-critical computation is wire-breaking, and ships a single game ABI. | @@ -218,26 +211,25 @@ RFC 2119. Each fund-holding contract is split into two deployed contracts. -| Core (frozen, no admin, holds BZZ) | Policy (replaceable, holds no BZZ) | +| Core (frozen, holds BZZ, no admin power over held funds) | Policy (replaceable, holds no user deposits) | |---|---| -| `PostageAccounting` — batch ownership, per-batch normalised balance, the outpayment accumulator, pot, total deposited, total paid out | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price submission, expiry ordering | -| `StakingCore` — per-address deposit, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | +| `PostageAccounting` — batch ownership, depth, per-batch normalised balance, the outpayment accumulator, valid-chunk count, expiry ordering, pot | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price submission | +| `StakingCore` — per-account deposit, first-deposit block, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | -`Redistribution` and `PriceOracle` are policy-class contracts: they hold no user funds and -are plain redeployments, never proxies (see Part 2). +`Redistribution` and `PriceOracle` are policy-class contracts: they hold no user deposits +and are plain redeployments, never proxies (see Part 2). `Redistribution` MAY transiently +hold pot funds between `claimPot` and winner payout (C4). -Cores MUST NOT be deployed behind a proxy. Cores MUST NOT contain `delegatecall`. Policies -MAY be deployed behind a proxy or MAY be plain redeployments; this SWIP does not mandate -either, because C2 makes the choice non-custodial. Given F1, plain redeployment is expected -to be simpler in practice. +Cores MUST NOT be deployed behind a proxy and MUST NOT contain `delegatecall`. Policies MAY +be proxied or plainly redeployed; C2 makes the choice non-custodial, and given F1 plain +redeployment is expected in practice. #### C2. Core invariants -A split that does not satisfy them buys nothing: it relocates the trust boundary by one hop -and leaves it exactly as wide. The current architecture already has the shape "frozen -ledger, swappable policy" — a `PostageStamp` that never changes, with a replaceable -`Redistribution` authorised on it — and it leaks full custody anyway, because -`withdraw(beneficiary)` is an unconstrained primitive. +The split is not the security property; the invariants are. The current architecture +already has the shape "frozen ledger, swappable policy" — a `PostageStamp` that never +changes with a replaceable `Redistribution` authorised on it — and it leaks full custody +anyway, because `withdraw(beneficiary)` is an unconstrained primitive. **C2.1 — No caller-supplied destinations.** A core MUST NOT transfer tokens to an address supplied by the caller or by policy. Every destination MUST be derived from the core's own @@ -248,24 +240,39 @@ recorded state: - `PostageAccounting.claimPot(amount)` pays the single authorised redistributor address, which is itself set only via C2.5. -**C2.2 — Conservation, enforced by the core.** Each core MUST track total deposited and -total paid out, and MUST maintain, checked at the end of every state-changing call: +**C2.2 — Conservation, enforced by the core.** Each core MUST maintain, checked at the end +of every state-changing call: ``` -sum(recorded claims) + pot <= token.balanceOf(core) +pot + sum(remaining batch claims) <= token.balanceOf(core) (PostageAccounting) +totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore) ``` -The invariant MUST be maintained incrementally on each call, not recomputed by iterating -balances, and MUST be the subject of the fuzz coverage required by [Test cases](#test-cases). +maintained incrementally, never by iterating balances. For `PostageAccounting` this +requires the core to own the accrual identity that today lives in +`PostageStamp.expireLimited`: + +- the core records each batch's **depth** at creation, and maintains `validChunkCount` + and `lastExpiryBalance` as its own aggregates; +- before any `claimPot` and before conservation is checked, accrued outpayment MUST be + settled: expired batches contribute `batchSize * (normalisedBalance - + lastExpiryBalance)`, live chunks contribute `validChunkCount * (currentTotalOutPayment() + - lastExpiryBalance)`; +- settlement of live-chunk accrual MUST NOT run while an expired batch is still counted in + `validChunkCount`, since that would credit the pot beyond the batch's backing. + +The last bullet is why the expiry **ordering structure stays in the core** (C2.7): the core +can only know that no expired batch remains counted by knowing the minimum normalised +balance. An ordering index in policy would make conservation depend on policy honesty, +violating C2.3. -The core MUST own exactly enough arithmetic to police this and no more. Unbacked-batch -creation (`copyBatch`) is precisely a breach of the inequality above, and under C2.2 no -policy — honest, buggy, or malicious — can reproduce it. +Unbacked batch creation (`copyBatch`) is precisely a breach of the first inequality, and +under C2.2 no policy — honest, buggy, or malicious — can reproduce it. **C2.3 — One-way calls.** Calls MUST go policy → core only. A core MUST NOT call, delegate -to, or read from its policy, and MUST NOT expose callbacks or hooks. Core correctness MUST -NOT depend on policy code. A corollary: a core cannot ask policy whether an action is -permitted; every check a core performs is self-contained. +to, or read from its policy, and MUST NOT expose callbacks or hooks. A corollary: a core +cannot ask policy whether an action is permitted; every check a core performs is +self-contained. **C2.4 — Bounded authority.** Every value-moving primitive a policy can trigger MUST be rate-limited by the core: @@ -274,22 +281,28 @@ rate-limited by the core: |---|---| | `claimPot(amount)` | at most `MAX_POT_FRACTION_PER_WINDOW` of `pot` per `CLAIM_WINDOW` blocks | | `slash(account, amount)` | at most `MAX_SLASH_PER_WINDOW` in aggregate per `SLASH_WINDOW` blocks | -| `setPrice(price)` | `price <= MAX_PRICE`, and the step from `lastPrice` at most `MAX_PRICE_CHANGE_PER_UPDATE` | +| `setPrice(price)` | `price <= MAX_PRICE`, step from `lastPrice` at most `MAX_PRICE_CHANGE_PER_UPDATE` | The windows are core-owned block counts, not the redistribution game's round length, which -is per-branch and replaceable (F1). These are deployment parameters, immutable in the core -once set; their values are an open question. The bounds are not meant to make theft -impossible in the limit — they make it *slow and visible*, so the exit in C2.6 has a usable -window. +is per-branch and replaceable (F1). The parameters are immutable once set; values are an +open question. + +Be precise about what the pot bound buys. The honest game already pays the whole pot to a +winner every round, so a `claimPot` cap at or above the honest rate does not slow a +malicious redistributor below normal outflow — it caps *acceleration*. The protections +against a hostile policy are the C2.5 timelock (it cannot be installed silently) and the +C2.6 exit (users can leave during the announcement window); the C2.4 bounds exist so that +even an installed hostile policy cannot flash-drain what has accrued between exits. **C2.5 — Timelocked pointers, enforced by the core.** A core MAY allow its policy pointer or its redistributor pointer to change, and if it does: - the change MUST be proposed and then executed no earlier than `POLICY_TIMELOCK` blocks later, with both proposal and execution emitting events; -- the timelock MUST be enforced by the core itself, not by an external timelock contract - that a role could replace; -- `POLICY_TIMELOCK` MUST be immutable. +- the proposer (the governing address) MAY cancel a pending change at any time before + execution; cancellation MUST NOT extend or shorten any other pending change; +- the timelock MUST be enforced by the core itself, not by an external timelock contract a + role could replace, and `POLICY_TIMELOCK` MUST be immutable. A core with a timelocked pointer **has a privileged operation** and is not admin-free. The claim is narrower and checkable: *no privileged operation can move a user's deposit, and @@ -304,31 +317,39 @@ every privileged operation is announced in advance with a guaranteed exit window claims. Concretely: `StakingCore.exit()` returns the caller's recorded deposit, and -`PostageAccounting.refundBatch(batchId)` returns the batch's remaining balance to its owner. -`exit()` MUST be preceded by `requestExit()` and callable `EXIT_DELAY` blocks later — a -fixed unbonding period, not a role-gated pause — so it cannot be used to dodge in-flight -slashing. - -The postage exit needs an economic guard, because a batch owner could otherwise top up, -upload, and immediately refund, obtaining storage for free. `refundBatch` SHOULD forfeit a -fixed fraction of the remaining balance to the pot, or be subject to a minimum batch age. -This is an economic parameter, not a security one, and is listed as an open question. +`PostageAccounting.refundBatch(batchId)` returns the batch's remaining balance to its +owner. `exit()` MUST be preceded by `requestExit()` and callable `EXIT_DELAY` blocks later +— a fixed unbonding period, not a role-gated pause — so it cannot be used to dodge +in-flight slashing. + +Two consequences are acknowledged rather than hidden: + +- **`exit()` is withdrawable stake.** Today's `StakeRegistry` only returns surplus above + the committed stake; a general unbonding exit is a change to staking economics, aligned + with the ongoing withdrawable-stake discussion, and `EXIT_DELAY` MUST be at least the + maximum freeze horizon the game can impose, or exit dodges penalties. +- **`refundBatch` changes the storage promise.** Today a batch balance is a commitment no + one can retract; under this SWIP a mutable batch is revocable mid-life. Nodes MUST treat + a refund event as batch invalidation (the same handling as expiry, on a new trigger), and + clients MUST observe refund events. Because stamp validity is consensus-adjacent, the + cutover that introduces `refundBatch` MUST be treated as Type A (F7). `refundBatch` + SHOULD forfeit a fixed fraction of the remaining balance to the pot so that + top-up/upload/refund is not free storage; the fraction is an open question. Immutable + batches (`immutableFlag`) are not refundable. **C2.7 — Frozen means frozen.** Cores have no upgrade path. This is the risk the proposal -takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin is -worse than an upgradeable contract. Therefore: - -- Cores hold balances, ownership, the outpayment accumulator, the pointers and bounds their - own invariants need, and the conservation check. -- Everything with interesting edge cases — the expiry ordering structure, batch selection, - depth and bucket rules, effective-stake curves, commitment maths — lives in policy, where - it can be fixed. +takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin +is worse than an upgradeable contract. Therefore: + +- Cores hold balances, ownership, batch depth, the outpayment accumulator, the expiry + ordering, the pointers and bounds their own invariants need, and the conservation check. +- Batch admissibility rules, effective-stake curves, commitment maths, overlay derivation + live in policy, where they can be fixed. - Cores MUST be formally specified and MUST have full invariant and fuzz coverage before deployment (see [Test cases](#test-cases)). -The outpayment accumulator is the one piece of pricing arithmetic that cannot live in the -replaceable half. A batch's `normalisedBalance` is denominated *in the accumulator of the -contract that issued it*: +The outpayment accumulator cannot live in the replaceable half. A batch's +`normalisedBalance` is denominated *in the accumulator of the contract that issued it*: ``` currentTotalOutPayment() = totalOutPayment + lastPrice * (block.number - lastUpdatedBlock) @@ -336,35 +357,38 @@ remainingBalance(id) = max(0, normalisedBalance[id] - currentTotalOutPayment ``` A fresh contract starts the accumulator at zero, so every balance must be *rebased*, not -re-pointed — which is exactly what today's `copyBatch` does when it recomputes -`normalisedBalance = currentTotalOutPayment() + remainingBalance`. If the accumulator lived -in policy, every policy replacement would rebase every batch, turning a once-per-migration -hazard into a once-per-upgrade one: wrong expiry and premature reserve eviction. That would -be strictly worse than the status quo. - -The cost of putting it in the core is that **the outpayment model itself is frozen**: linear -per-block accrual against a per-chunk normalised balance. Moving to a different model — -non-linear pricing, per-neighbourhood pricing, a different unit of account — is not a policy -change and would still require a migration. This is the largest single thing the proposal -gives up. +re-pointed — which is what today's `copyBatch` does. If the accumulator lived in policy, +every policy replacement would rebase every batch: wrong expiry and premature reserve +eviction, once per upgrade instead of once per migration. + +The cost is that **the outpayment model itself is frozen**: linear per-block accrual +against a per-chunk normalised balance. Moving to non-linear or per-neighbourhood pricing +is not a policy change and would still require a migration. This is the largest single +thing the proposal gives up; the one considered alternative is recorded in +[Open questions](#open-questions). + +The expiry ordering structure in the core is the second-largest C2.7 risk: it is the most +edge-case-heavy component in the current contract, and under this SWIP it becomes +unfixable. It stays in the core because C2.2 requires it (see above); the compensation is +the mandatory adversarial and differential test burden in [Test cases](#test-cases). #### C3. `StakingCore` interface -Staking is the easier of the two cases and SHOULD be done first: its only funds-out -direction is already "pay `msg.sender`", so C2.1 is satisfiable without changing any user's -economics, and today's rug-resistance is preserved exactly rather than approximated. +Staking is the easier case and SHOULD be done first: its only funds-out direction is +already "pay `msg.sender`", so C2.1 is satisfiable without changing any user's economics. ```solidity interface IStakingCore { // ---- user ---- /// @notice Deposit BZZ. Credited to msg.sender. No policy call. + /// Records firstDepositBlock on the account's first deposit. function deposit(uint256 amount) external; /// @notice Withdraw up to `amount` of the caller's unlocked deposit. Pays msg.sender only. function withdraw(uint256 amount) external; - /// @notice Permissionless exit (C2.6). Not pausable, ignores policy state. - /// Callable EXIT_DELAY blocks after requestExit(). + /// @notice Permissionless exit (C2.6). Not pausable, ignores policy locks. + /// exit() callable EXIT_DELAY blocks after requestExit(). function requestExit() external; function exit() external; @@ -379,109 +403,121 @@ interface IStakingCore { // ---- views ---- function depositOf(address account) external view returns (uint256); + function firstDepositBlock(address account) external view returns (uint64); function totalDeposited() external view returns (uint256); } ``` `StakingCore` MUST NOT store overlays, heights, committed stake, or effective stake, and -MUST NOT read `PriceOracle`. Those are per-branch, consensus-critical values, and belong in -`StakingPolicy`. Overlay derivation is network-scoped — it mixes `NetworkId` — so it should -be redeployed with a fork, while deposits should not. +MUST NOT read `PriceOracle`. Those are per-branch, consensus-critical values and belong in +`StakingPolicy`. Overlay derivation mixes `NetworkId`, so it is redeployed with a fork; +deposits are not. `StakingPolicy` SHOULD accept an immutable `predecessor` address and lazily inherit overlay -and height from it on first use, so that a fork requires no operator transaction and no -eligibility delay. A fresh declaration would instead cost roughly two rounds (~25 minutes at -`ROUND_LENGTH = 152` on Gnosis). +and height from it on first use, so a fork requires no operator transaction and no +eligibility delay. **C3.1 — Eligibility clock.** `StakingPolicy` MUST compute participation eligibility from -`min(depositBlock, preRegistrationBlock)`, where pre-registration is a zero-value -transaction an operator MAY send in advance of a deposit or a cutover. +`min(firstDepositBlock, preRegistrationBlock)`, where `firstDepositBlock` is the +core-recorded value above and pre-registration is a zero-value transaction an operator MAY +send in advance of a deposit or a cutover. `Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before a node may participate. Without a pre-registration clock, any event that makes many operators -establish a stake record at similar times produces a **rolling participation trough**. -While the spread lasts, effective participation is a fraction of normal, and with few -participants a single dissenter's chance of being leader rises sharply — the v2.8.0 failure -mode, self-inflicted. Staggering the event to avoid a gas spike lengthens the trough rather -than fixing it. Pre-registration lets the settling period elapse beforehand, so no operator -waits at cutover. - -**C3.2 — Accounts and nodes.** `StakingCore` MUST record deposits per *account* and MUST NOT -assume a one-to-one relationship between an account and a node identity. Mapping an account -to one or more node overlays is `StakingPolicy`'s responsibility, since overlay derivation -is already policy-side. - -This is close to free once overlay lives in policy, and it has three consequences: - -- fleet operations become proportional to accounts rather than nodes, so a large operator - can fund or exit an entire fleet in one transaction; -- withdrawal authority is separated from the node's operational signer, so a compromised - node key cannot move funds; -- the cost of the one final stake migration falls sharply. - -It introduces one question the policy MUST answer explicitly: if several nodes are backed by -one account, a slash earned by one node reduces the stake backing the others. Acceptable -answers include per-node sub-allocations within an account, or requiring an account's -deposit to cover the sum of its nodes' committed stakes. This SWIP does not pick one; see -[Open questions](#open-questions). +establish stake records at similar times produces a rolling participation trough, during +which a single dissenter's chance of being leader rises sharply — the v2.8.0 failure mode, +self-inflicted. Staggering the event lengthens the trough rather than fixing it; +pre-registration lets the settling period elapse beforehand. + +**C3.2 — Accounts and nodes.** `StakingCore` records deposits per *account*; mapping an +account to one or more node overlays is `StakingPolicy`'s responsibility. Consequences: +fleet operations scale with accounts rather than nodes; withdrawal authority is separated +from the node's operational signer, so a compromised node key cannot move funds; and the +final stake migration's cost falls sharply. + +Shared-account slashing is resolved by a **coverage requirement**: `StakingPolicy` MUST NOT +admit a set of overlays for an account whose summed committed stake exceeds the account's +core-recorded deposit, and a slash reduces the account's deposit (and therefore, at the +policy layer, the eligibility of all overlays it backs). Per-node sub-allocations were +considered and rejected as policy-side complexity the core cannot verify. #### C4. `PostageAccounting` interface ```solidity interface IPostageAccounting { - // ---- user ---- - /// @notice Fund a batch id. Amount is transferred in; credited to `owner`. - function fund(bytes32 batchId, address owner, uint256 amount) external; + // ---- policy-gated: batch admissibility lives in PostagePolicy ---- + /// @notice Create and fund a batch. The core derives the id from + /// (originator, nonce), records depth, transfers the total in from + /// the policy's caller, and credits the normalised balance. + function fund( + address originator, bytes32 nonce, address owner, + uint8 depth, bool immutableFlag, uint256 amountPerChunk + ) external returns (bytes32 batchId); + + /// @notice Change a batch's depth. The core preserves total remaining value, + /// recomputing the per-chunk balance and validChunkCount. + function resize(bytes32 batchId, uint8 newDepth) external; + + /// @notice Submit a new price. The core folds it into its own accumulator. + /// Bounded by MAX_PRICE and MAX_PRICE_CHANGE_PER_UPDATE (C2.4). + function setPrice(uint256 price) external; - /// @notice Add funds to an existing batch. Owner unchanged. - function topUp(bytes32 batchId, uint256 amount) external; + /// @notice Pay out to the single authorised redistributor. Capped per + /// window (C2.4). Destination is not a parameter. + function claimPot(uint256 amount) external; + + // ---- user, direct on the core ---- + /// @notice Add funds to an existing batch. Owner and depth unchanged. + function topUp(bytes32 batchId, uint256 amountPerChunk) external; /// @notice Owner-only exit, no role check (C2.6). Pays ownerOf(batchId). - /// May forfeit a fixed fraction to the pot (see C2.6). + /// Forfeits a fixed fraction to the pot. Reverts for immutable batches. function refundBatch(bytes32 batchId) external; - /// @notice Credit the pot for batches that have reached zero balance. - /// Permissionless. For each id the core verifies remainingBalance(id) == 0 - /// for itself; ordering hints are not trusted. + /// @notice Retire batches whose remaining balance the core verifies as zero, + /// settling accrual per C2.2. Permissionless; ids are hints. function expire(bytes32[] calldata batchIds) external; - // ---- policy, bounded (C2.4) ---- - /// @notice Submit a new price. The core folds it into its own accumulator. - /// Bounded by MAX_PRICE and MAX_PRICE_CHANGE_PER_UPDATE. - function setPrice(uint256 price) external; - - /// @notice Pay out to the single authorised redistributor. Capped per round (C2.4). - /// Destination is not a parameter. - function claimPot(uint256 amount) external; - // ---- redistributor pointer (C2.5, F4) ---- function proposeRedistributor(address next) external; + function cancelRedistributor() external; function executeRedistributor() external; // ---- views ---- function remainingBalance(bytes32 batchId) external view returns (uint256); function normalisedBalanceOf(bytes32 batchId) external view returns (uint256); - function currentTotalOutPayment() external view returns (uint256); + function depthOf(bytes32 batchId) external view returns (uint8); function ownerOf(bytes32 batchId) external view returns (address); + function currentTotalOutPayment() external view returns (uint256); + function validChunkCount() external view returns (uint256); function pot() external view returns (uint256); function redistributor() external view returns (address); } ``` -There is no `withdraw(address)`. The redistributor pointer is singleton by construction -rather than by role hygiene, which is the direct fix for the v0.9.3 double-redistributor +There is no `withdraw(address)` and no unbacked creation path. The redistributor pointer is +singleton by construction, which is the direct fix for the v0.9.3 double-redistributor race. -`claimPot` takes an amount but not a destination, and there is no `accrue` primitive: the -core derives every batch's remaining balance from its own accumulator. Policy therefore has -no pot-accrual authority. - -Batch *identity and semantics* — bucket depth validity, immutability flags, minimum initial -balance, depth-increase rules — live in `PostagePolicy`. The expiry *ordering* structure -(today `HitchensOrderStatisticsTreeLib`) also lives in policy: it is a search index over -core state, rebuildable from events, and the single most edge-case-heavy component in the -current contract, so it belongs in the half that can be fixed. Ordering is only a hint — -`expire` verifies each id against the core's own balance. +**Call topology.** `fund` and `resize` are policy-gated: admissibility (minimum balance, +bucket-depth rules, mutability) is checked in `PostagePolicy` before it forwards to the +core, so a dead or hostile policy can block *creation* — a liveness cost bounded by the +C2.5 timelock — but never block `topUp`, `refundBatch`, `expire`, or conservation, which +are direct on the core. Price submission flows `PriceOracle` → `PostagePolicy` → +`setPrice`, and the C2.4 price bounds MUST be compatible with the oracle's adjustment +steps. + +**Batch identity.** New batch ids MUST derive from `(originator, nonce)`, preserving +today's `keccak256(sender, nonce)` binding so an announced id cannot be front-run by a +third party. Ids not derived this way exist only as genesis-seeded state (see +[Backwards compatibility](#backwards-compatibility)); after genesis is sealed there is no +path that accepts an arbitrary id. + +**Pot custody in `claimPot`.** The winner payout becomes two hops: the core pays the +authorised `Redistribution`, which pays the winner. `Redistribution` therefore transiently +holds pot funds; its claim path SHOULD complete both hops in one transaction, and any BZZ +stranded in a `Redistribution` by a failed payout is governance-recoverable there — it is a +policy-class contract holding protocol funds, not user deposits. #### C5. Residual trust after Part 1 @@ -490,16 +526,15 @@ current contract, so it belongs in the half that can be fixed. Ordering is only | Steal all staked BZZ | No (burn only) | No | | Burn all staked BZZ | Yes (redistributor role) | No — capped per window (C2.4) | | Steal the entire pot in one call | Yes (`withdraw(beneficiary)`) | No — no such primitive (C2.1) | -| Drain the pot over time | Yes | Bounded, visible, timelocked (C2.4, C2.5) | +| Drain the pot over time | Yes | At most the honest payout rate, timelocked and announced (C2.4, C2.5) | | Create unbacked batch state | Yes (`copyBatch`) | No (C2.2) | | Misdirect *future* rewards | Yes | Yes, after `POLICY_TIMELOCK`, announced | | Close the user escape hatch | Yes (`DEFAULT_ADMIN_ROLE`) | No (C2.6) | -Two capabilities survive: draining the pot slowly within the C2.4 bounds, and misdirecting -future rewards after `POLICY_TIMELOCK`. **Custody separation protects deposits, not -rewards.** Bounding reward direction further would require freezing redistribution -verification itself, which conflicts with F1's requirement that `Redistribution` be -redeployed per fork. +Two capabilities survive: claiming the pot at up to the honest rate through a hostile +redistributor, and misdirecting future rewards after `POLICY_TIMELOCK`. **Custody +separation protects deposits, not rewards.** Bounding reward direction further would +require freezing redistribution verification itself, which conflicts with F1. ### Part 2 — Fork migration @@ -508,22 +543,22 @@ redeployed per fork. A **fork** of the Swarm network is a second network whose initial state is a clone of a subset of the first's — canonically, of the stamp set. A **fork-migration** is a fork in which the old branch is intended to be wound down. Every breaking change to the Swarm wire -protocol to date has been a fork-migration (*Forking Swarm*). +protocol to date has been a fork-migration. A **breaking wire release** is a client release whose peer-negotiated protocol version -differs from its predecessor's, so that mismatched peers disconnect. A breaking wire release -therefore always produces at least two disjoint p2p networks. +differs from its predecessor's, so that mismatched peers disconnect and at least two +disjoint p2p networks result. #### F1. A new `Redistribution` per breaking wire release Every breaking wire release MUST be accompanied by the deployment of a new `Redistribution` contract, **even if its bytecode is unchanged**. -Rationale: contract identity, not the wire version, is what partitions the incentive game. -Without a new `Redistribution`, both branches play the same game with divergent views of the -stamp set — a negative-sum outcome in which stragglers claim a share of payments intended -for the new branch, upgraded nodes earn less, and honest nodes can be frozen for -disagreeing with a non-upgraded leader. This is the measured v2.8.0 failure mode. +Contract identity, not the wire version, is what partitions the incentive game. Without a +new `Redistribution`, both branches play the same game with divergent views of the stamp +set — a negative-sum outcome in which stragglers claim payments intended for the new +branch, upgraded nodes earn less, and honest nodes are frozen for disagreeing with a +non-upgraded leader. This is the measured v2.8.0 failure mode. `Redistribution` holds no state worth preserving, so this is close to free. It is the cheapest recommendation in this SWIP and SHOULD be adopted as standing practice @@ -537,7 +572,7 @@ A `Cutover` contract publishes the schedule: interface ICutover { struct Schedule { uint32 wireVersion; // client protocol version this cutover activates - uint64 activationBlock; // MUST be a multiple of ROUND_LENGTH (F3) + uint64 activationBlock; // MUST satisfy F3 alignment bytes32 manifest; // hash of the release's address set } @@ -549,73 +584,73 @@ interface ICutover { } ``` -Two normative rules govern its use. - **F2.1 — The signal carries timing; the binary carries addresses.** A client MUST NOT learn a contract address from the chain and act on it. Contract addresses MUST be compiled into the client release. The `Cutover` contract may tell a client *when* to switch; it MUST NOT be able to tell it *where*. The `manifest` field is a hash the client checks against its -own compiled address set, and a mismatch MUST be a hard failure, not a warning. - -This rule exists because the alternative is an automated fund-redirection trigger. A client -that reads a destination address from chain and then moves the operator's stake to it has -reproduced, inside the client, exactly the admin power this SWIP removes from the contracts. +own compiled address set, and a mismatch MUST be a hard failure. The alternative — a client +that reads a destination from chain and moves funds toward it — reproduces, inside the +client, exactly the admin power this SWIP removes from the contracts. **F2.2 — Schedules are event-driven, not height-hardcoded.** Clients MUST determine -activation by observing `Cutover` state, not by a height baked into the binary. A hardcoded -height fixes the date at release-engineering time; if the date must slip — a bug is found, -the multisig cannot assemble, the chain has an incident — every client in the field holds -the wrong height and an emergency release is required. A rescheduled `activationBlock` MUST -be re-announced at least `CUTOVER_NOTICE` blocks before the new activation. - -#### F3. Round-aligned atomic cutover - -`activationBlock` MUST satisfy `activationBlock % ROUND_LENGTH == 0`. - -Cutover is not instantaneous with respect to the redistribution game. A round spans -`ROUND_LENGTH` blocks (152 at present) and is divided into commit, reveal and claim phases. -A cutover landing mid-round orphans nodes that have already committed: they lose their -reveal window and may be frozen for a phase violation they did not cause. - -Therefore: - -- The incoming `Redistribution` MUST accept commits from `activationBlock` onward. -- The authority change on `PostageAccounting` (F4) MUST execute at `activationBlock`. +activation by observing `Cutover` state, not by a height baked into the binary, so a +slipped date does not require an emergency release. A rescheduled `activationBlock` MUST be +re-announced at least `CUTOVER_NOTICE` blocks before the new activation. + +**F2.3 — `Cutover` governance.** Schedules are written by the governing multisig. The +`Cutover` contract holds no funds and no fund-moving authority, so its failure mode is +liveness, not custody: a hostile or absent scheduler can delay cutovers, never redirect +money. Scheduling and rescheduling MUST emit events, and a schedule inside its +`CUTOVER_NOTICE` window MUST NOT be modified — cancellation counts as rescheduling. + +#### F3. Round-aligned cutover with a bounded execution window + +`activationBlock` MUST fall on a round boundary **of the outgoing game**: +`activationBlock % ROUND_LENGTH_outgoing == 0`. A cutover landing mid-round orphans nodes +that have committed: they lose their reveal window and may be frozen for a phase violation +they did not cause. If a release changes `ROUND_LENGTH`, that change is Type A (F7), and +the incoming game starts at a boundary of the outgoing one. + +Exact-block execution cannot be demanded of a multisig, and "not before" (a bare timelock) +is not "at". Therefore: + +- `executeRedistributor()` MUST be valid only within + `[activationBlock, activationBlock + EXECUTION_WINDOW)` for the scheduled cutover, where + `EXECUTION_WINDOW` is a core constant well under one round; +- the incoming `Redistribution` MUST accept commits from `activationBlock` onward; +- until execution, the outgoing redistributor remains authorised, so a late execution + inside the window shortens the first new round's claim rather than orphaning anyone. A gap in redistributor coverage and an orphaned round are distinct failures; the rules -above prevent both. +above prevent both without demanding single-block inclusion. #### F4. Exactly one redistributor, by construction `PostageAccounting` MUST authorise at most one redistributor address at any block. The pointer changes only through `proposeRedistributor` / `executeRedistributor` under -`POLICY_TIMELOCK` (C2.5), and `claimPot` reverts for any caller that is not the current -pointer. +`POLICY_TIMELOCK` (C2.5) and the F3 window, and `claimPot` reverts for any caller that is +not the current pointer. -This replaces `REDISTRIBUTOR_ROLE` as an `AccessControl` role, under which multiple holders -are representable and were in fact simultaneously authorised in 2025. Here it is a property -of the type, not of operational discipline. - -Cutover execution is therefore: `executeRedistributor()` on `PostageAccounting`, plus the -policy pointer update if policy changed, in a single transaction from the governing -multisig. It MUST be a single transaction: "atomic" is not satisfied by several transactions -sent close together, as the v0.9.3 incident shows. +This replaces `REDISTRIBUTOR_ROLE`, under which multiple simultaneous holders are +representable and were in fact simultaneously authorised in 2025. Here singleton authority +is a property of the type, not of operational discipline. (Until `PostageAccounting` +exists, F4 can only be honoured operationally — see stage 1 in +[Implementation](#implementation).) #### F5. Old-branch wind-down Immediately zeroing rewards on the old branch is correct for incentive alignment and wrong for data availability: old-branch data stays retrievable only while old-branch nodes stay -online, and zeroing rewards is what takes them offline. - -Where a fork requires user-side action with a tail — a wire-protocol change, since batches -themselves now carry across — the schedule SHOULD include a wind-down window during which -the old `Redistribution` continues to pay at a reduced rate, decaying to zero. This is a -payment overlap, not an authority overlap: the retired `Redistribution` is never the core's -authorised pointer during the wind-down, so F4 is not relaxed. How the window is funded is -an open question. +online. -The residual pot in any retired core MUST have a defined destination. This SWIP does not -fix one; see [Open questions](#open-questions). +Where a fork leaves user-side action with a tail, the schedule MAY include a wind-down +window during which the outgoing `Redistribution` continues paying at a reduced, decaying +rate. Its funding MUST be transferred into the outgoing `Redistribution` **before** +cutover — from the treasury or from a final pre-cutover `claimPot` — because after +cutover the retired contract is no longer the authorised pointer and MUST NOT regain pot +access. This is a payment overlap, never an authority overlap; F4 is not relaxed. Funds +left in a retired `Redistribution` after wind-down are governance-recoverable (it is +policy-class and holds no user deposits). #### F6. Client requirements @@ -626,38 +661,33 @@ A conforming client: 2. MUST read `Cutover` for timing only, and MUST hard-fail on `manifest` mismatch (F2.1). 3. MUST switch the `Redistribution` address it uses at `activationBlock`, not when the operator restarts. -4. MUST NOT send any fund-moving transaction as an automated consequence of a chain signal. - Under Part 1 no such transaction is required at cutover. -5. SHOULD expose the pending cutover in its status API and log a warning when it is running a +4. MUST NOT send any fund-moving transaction as an automated consequence of a chain + signal. Under Part 1 no such transaction is required at cutover. +5. SHOULD expose the pending cutover in its status API and log a warning when running a version whose cutover has passed. #### F7. Cutover types and dual-ABI scope -Two kinds of cutover exist and they carry different client obligations. Conflating them is -what makes the dual-ABI burden look unbounded. - **Type A — wire-breaking.** The release changes the p2p protocol version, so vN and vN+1 -nodes cannot peer at all. The client ships a *single* game ABI. A node that has not upgraded -by `activationBlock` stops earning, which is intended and is the entire content of F1. No -dual-mode code is required, because a non-upgraded node is on the other branch and must not -be paid from this branch's pot. +nodes cannot peer. The client ships a *single* game ABI. A node that has not upgraded by +`activationBlock` stops earning — intended, and the entire content of F1. No dual-mode code +is required, because a non-upgraded node is on the other branch and must not be paid from +this branch's pot. **Type B — contract-only.** The wire protocol is unchanged: a `Redistribution` bugfix, a -policy parameter change, a new `PostagePolicy`. Continuity is expected — operators running a -release that carries both bindings MUST keep earning across `activationBlock` — so the -client MUST carry both and switch at `activationBlock`. The legacy binding MAY be removed in the -first release after the cutover. +policy parameter change, a new `PostagePolicy`. Continuity is expected — operators running +a release that carries both bindings MUST keep earning across `activationBlock` — so the +client MUST carry both bindings and switch at `activationBlock`. The legacy binding MAY be +removed in the first release after the cutover. **F7.1 — Consensus-path rule.** A cutover that would require a runtime branch in consensus-critical computation — reserve sampling, commitment hashing, overlay derivation, -depth or eligibility determination — MUST be Type A. It MUST NOT be shipped as Type B with a -runtime branch. - -The reason is that a dual-mode sampler is itself a source of dissent: two nodes that -disagree about which mode they are in produce divergent reserve commitments, which is -precisely the failure mode F1 exists to prevent. F7.1 confines Type B's dual-mode surface to -contract call sites, where it is cheap, and pushes anything deeper into Type A, where the -network partition already does the separating. +depth or eligibility determination, stamp-validity rules — MUST be Type A. A dual-mode +sampler is itself a source of dissent: two nodes disagreeing about which mode they are in +produce divergent reserve commitments, the exact failure F1 exists to prevent. This applies +even when the wire version would not otherwise change: a change to overlay derivation, the +eligibility clock, or the stamp-validity view (such as introducing `refundBatch`, C2.6) +MUST ship as Type A. F7.1 confines Type B's dual-mode surface to contract call sites. Under Part 1 the frozen cores never acquire a second ABI, so deposits, withdrawals and balance reads never branch in either type. Only policy and `Redistribution` bindings do. @@ -666,130 +696,143 @@ balance reads never branch in either type. Only policy and `Redistribution` bind Part 2 alone still requires a stake migration at every fork, and therefore an interval in which upgraded operators cannot earn. Part 1 alone leaves the fork boundary undefined, so -wire-only forks keep commingling incentives. Together: - -- Deposits never move, so cutover involves no user or operator fund transaction (F6.4). -- `Redistribution` identity still changes per fork, so branches never share a game (F1). -- Batches carry across, so there is no batch migration and `copyBatch` can be retired. -- The interval "between release and pausing the old registry" collapses to zero, because - there is nothing to pause and nothing to move. +wire-only forks keep commingling incentives. Together: deposits never move at cutover +(F6.4); `Redistribution` identity still changes per fork (F1); batches carry across, so +there is no batch migration; and the interval "between release and pausing the old +registry" collapses to zero, because there is nothing to pause and nothing to move. ## Rationale -**Why not "always full redeploy".** *Forking Swarm*'s proposal is coherent but -expensive, and its cost is not bounded in the document. It requires a batch migration at -every breaking wire release, and it leaves batch migration undesigned. It also relies on an -incentive asymmetry that does not hold: operators will migrate stake to keep earning, but a -user who fails to migrate a batch loses availability they may not notice until they need the +**Why not "always full redeploy".** *Forking Swarm*'s proposal requires a batch migration +at every breaking wire release, leaves batch migration undesigned, and relies on an +incentive asymmetry that does not hold: operators migrate stake to keep earning, but a user +who fails to migrate a batch loses availability they may not notice until they need the data. Part 1 removes the requirement rather than solving the coordination problem. -**Why the registry survives as a cutover signal.** The question raised on -[`storage-incentives#310`][pr310] — who benefits from an on-chain registry, and how does it -compare to publishing under ENS or on GitHub — has a straight answer. For *security* it adds -nothing: the trust root is the client release process either way. For *coordination* it adds -something real: it lets every client switch at the same block regardless of when its -operator restarted. F2 keeps the coordination and F2.1 removes the security temptation. +**Why the registry survives as a cutover signal.** For *security* an on-chain registry adds +nothing: the trust root is the client release process either way. For *coordination* it +adds something real: every client switches at the same block regardless of when its +operator restarted. F2 keeps the coordination; F2.1 removes the security temptation. -**Why staking first.** Demonstrating a frozen core on the easy case — where funds already -only flow to `msg.sender`, and where the objection to upgradeability was strongest — earns -the standing to freeze the postage ledger afterwards. +**Why batch creation is policy-gated but exits are not.** Admissibility rules change per +branch and per policy generation; they cannot be frozen. Exits are the security property +and must not depend on any replaceable contract. The asymmetry is deliberate: a hostile +policy can stop new business, never trap existing funds. -**Why bounds rather than prohibitions.** A design in which policy has no authority at all -over funds cannot slash, cannot pay winners, and is therefore not an incentive system. The -achievable goal is not zero authority but *bounded, announced, visible* authority with a -usable exit. C2.4 through C2.6 are that goal made concrete. +**Why bounds rather than prohibitions.** A policy with no authority over funds cannot +slash and cannot pay winners, and is therefore not an incentive system. The achievable goal +is *bounded, announced, visible* authority with a usable exit — C2.4 through C2.6 made +concrete. **Alternatives considered and rejected.** -- *Immutable policy pointer in the core.* Strictly stronger, but then changing policy means +- *Immutable policy pointer in the core.* Strictly stronger, but changing policy then means a new core, which reintroduces migration and defeats the purpose. -- *External timelock contract owning the pointer.* Weaker than C2.5: whoever can replace the - timelock's owner can shorten the window. +- *External timelock contract owning the pointer.* Weaker than C2.5: whoever can replace + the timelock's owner can shorten the window. +- *Expiry ordering in policy.* Rejected: C2.2's accrual settlement requires the core to + know the minimum normalised balance (see C2.2); an ordering index the core cannot trust + would make conservation depend on policy honesty. +- *User-driven batch migration through `fund()`.* Rejected as the primary path: the BZZ + backing existing batches is locked inside `PostageStamp`, which has no extraction path, + so "user-driven" means users pay twice. See Backwards compatibility. - *Governance vote on policy changes.* Orthogonal and compatible; this SWIP specifies the contract-level constraints that hold regardless of how the governing address is constituted. -- *Keeping the expiry tree in the core.* Rejected under C2.7: it is the most edge-case-heavy - component and the one most likely to need a fix. ## Backwards compatibility This is a breaking change to the contract suite and requires a coordinated release. It is also, by design, intended to be the **last** such change that moves user funds. -**Two migrations, once.** - -1. *Final stake migration.* Operators move deposits from `StakeRegistry` to `StakingCore`. - This is the last time. It SHOULD be run under the F2/F3 protocol, and — unlike 2025 — the - old registry MUST be paused at `activationBlock` rather than at an unrelated later date, - so that the interval between the client release and the pause is zero. -2. *Final batch migration.* Batches move from `PostageStamp` to `PostageAccounting`. This is - the last time. It is the harder of the two and SHOULD be user-driven wherever possible; - any assisted path for the tail MUST go through the ordinary `fund()` call, so migration is - deposit-matched and needs no privileged function in the core. `PostageAccounting` MUST NOT - include an unbacked batch-creation function, which is the specific defect in today's - `copyBatch`. - -**Client ABI.** Clients must learn a two-contract layout per subsystem: reads that are -consensus-critical (overlay, effective stake, batch validity) come from policy; balances and -deposits come from the core. Clients SHOULD read policy for anything that can change per -fork and core for anything that must not. - -**Integrators.** Anything reading `PostageStamp.batches(...)` or `StakeRegistry.stakes(...)` -directly must be updated. A compatibility view contract MAY be deployed to preserve the -current read ABI; it MUST be read-only and MUST NOT be depended on by clients for -consensus-critical values. +**Final stake migration.** Operators move deposits from `StakeRegistry` to `StakingCore` +via the existing `migrateStake()` path. It MUST be run under the F2/F3 protocol, and — +unlike 2025 — the old registry MUST be paused at `activationBlock`, so the interval between +the client release and the pause is zero. Operators SHOULD pre-register (C3.1) so no +eligibility trough opens. + +**Final batch migration: treasury-matched genesis.** `PostageStamp` cannot release +unexpired deposits (see Motivation), so the batch state must be seeded and separately +backed: + +1. At `activationBlock`, `PostageStamp` is paused (freezing `createBatch`, `topUp`, + `increaseDepth`; expiry and `withdraw` continue to operate). +2. `PostageAccounting` is deployed in a **genesis phase**: the deployer seeds the batch set + — id, owner, depth, bucket depth, immutability, remaining per-chunk balance — exactly as + of `activationBlock`, and MUST transfer in matching BZZ for the full seeded value. The + treasury fronts this float. +3. Genesis is **sealed** in the same ceremony. Until sealed, the core accepts no other + call; after sealing, no seeding path exists and C2.2 holds from the first open block. + Seeding MUST NOT be possible after sealing under any role. +4. The treasury is reimbursed from the old contract as seeded batches' old-side balances + expire into the old pot, using the existing `withdraw(beneficiary)` with the treasury as + beneficiary. This is the final, announced use of the unconstrained withdraw, and it + moves only money the treasury already fronted. The reimbursement horizon equals the + longest remaining batch life; the required float size is an open question. + +`PostageAccounting` contains **no unbacked creation function at any point** — the genesis +seed is deposit-matched by construction, which is the difference from `copyBatch`. + +**Client ABI.** Clients learn a two-contract layout per subsystem: consensus-critical reads +(overlay, effective stake, batch admissibility) from policy; balances, deposits, batch +depth and expiry from the core. A read-only compatibility view of the old +`batches(...)`/`stakes(...)` shapes MAY be deployed for integrators; clients MUST NOT +depend on it for consensus-critical values. ## Test cases -Cores are unupgradeable, so their test burden is qualitatively different from ordinary -contract tests. The following are mandatory before any core deployment. +Cores are unupgradeable, so the following are mandatory before any core deployment. **Invariant tests (must hold after every call, under all orderings).** -- `sum(recorded claims) + pot <= token.balanceOf(core)` (C2.2). -- `totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore)` (slashed BZZ is burnt - in place, so the balance exceeds the claims). -- No execution path transfers to an address not derived from core state (C2.1) — enforced by - a static check over the core's bytecode as well as by tests. +- `pot + sum(remaining claims) <= token.balanceOf(PostageAccounting)` (C2.2), including + across expiry, `resize`, `refundBatch` and price changes. +- `totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore)` (slashing burns in + place, so balance exceeds claims). +- Pot accrual identity: settled pot equals the sum over batches of + `batchSize * min(normalisedBalance, currentTotalOutPayment) - initial credit`, + differentially checked against `PostageStamp.expireLimited` over historical batch data. +- No execution path transfers to an address not derived from core state (C2.1) — enforced + by a static check over core bytecode as well as by tests. - No core function reaches an external call into the policy address (C2.3). -**Adversarial-policy tests.** Instantiate each core with a deliberately malicious policy -that attempts, at minimum: draining the pot in one call; slashing every account to zero; -claiming more than the per-round cap; accruing pot beyond conservation; blocking a user's -exit; setting a price above `MAX_PRICE`. Each MUST revert, and `exit()` MUST succeed -throughout. +**Adversarial-policy tests.** Instantiate each core with a malicious policy attempting, at +minimum: draining the pot in one call; slashing every account to zero; claiming beyond the +window cap; creating a batch whose transfer-in does not match the credited value; resizing +a batch to inflate remaining value; blocking a user's exit; setting a price beyond the +C2.4 bounds. Each MUST revert, and `exit()`/`refundBatch()` MUST succeed throughout. **Exit tests.** `exit()` and `refundBatch()` MUST succeed while the policy is malicious, -while the policy address is zero, while a policy change is pending in the timelock, and — for -`StakingCore` — while the account is locked by policy. +while the policy address is zero, while a pointer change is pending, and — for +`StakingCore` — while the account is policy-locked. -**Timelock tests.** A policy or redistributor change MUST NOT take effect before -`POLICY_TIMELOCK`; the pending change MUST be readable throughout the window. +**Genesis tests.** Seeding MUST revert without a matching deposit; any call before sealing +MUST revert; seeding after sealing MUST revert from every role; conservation MUST hold at +the first open block. -**Cutover tests.** A cutover at a round boundary MUST NOT orphan a committed node (F3); a -cutover proposed off-boundary MUST revert; a `manifest` mismatch MUST cause client -hard-failure. +**Timelock and window tests.** A pointer change MUST NOT execute before `POLICY_TIMELOCK` +nor outside `[activationBlock, activationBlock + EXECUTION_WINDOW)`; cancellation works +only before execution; the pending change is readable throughout. -**Accumulator continuity tests.** A policy replacement MUST NOT change -`currentTotalOutPayment()`, `normalisedBalanceOf()` or `remainingBalance()` for any batch. -This MUST be tested across a policy change with a pending price update, and across a policy -change that occurs mid-expiry. +**Cutover tests.** A boundary-aligned cutover MUST NOT orphan a committed node (F3); an +off-boundary schedule MUST revert; a `manifest` mismatch MUST hard-fail the client. -**Expiry self-verification tests.** `expire()` MUST credit the pot only for batch ids whose -`remainingBalance()` the core independently computes as zero, and MUST be safe when passed -arbitrary, duplicated, non-existent or not-yet-expired ids by an untrusted caller. +**Accumulator continuity tests.** A policy replacement MUST NOT change +`currentTotalOutPayment()`, `normalisedBalanceOf()` or `remainingBalance()` for any batch, +tested across a pending price update and mid-expiry. -**Price bound tests.** `setPrice` MUST reject a price above `MAX_PRICE` or a step above -`MAX_PRICE_CHANGE_PER_UPDATE`, from an honest and a malicious policy alike. +**Expiry self-verification tests.** `expire()` MUST credit the pot only for ids the core +independently computes as expired, MUST be safe under arbitrary, duplicated, non-existent +or unexpired ids, and live-chunk accrual MUST NOT settle while an expired batch remains +counted. **Eligibility clock tests.** A pre-registered operator MUST be eligible at -`activationBlock` without a settling delay (C3.1); a non-pre-registered operator MUST NOT -be. +`activationBlock` without settling delay (C3.1); a non-pre-registered one MUST NOT be. -**Fuzz and differential.** Fuzz the conservation invariant across randomised sequences of -deposit, top-up, price update, expire, claim, slash, withdraw and exit. Differentially test -core accounting against the current `PostageStamp` over historical batch data, to confirm -the split preserves today's remaining-balance and expiry results exactly. +**Fuzz and differential.** Fuzz conservation across randomised sequences of deposit, +fund, top-up, resize, price update, expire, claim, slash, refund, withdraw and exit. +Differentially test core accounting against the current `PostageStamp` over historical +data to confirm identical remaining-balance and expiry results. ## Implementation @@ -797,50 +840,49 @@ Staged so that each stage is independently valuable and independently revertible | Stage | Content | Depends on | |---|---|---| -| 1 | Surgical `Redistribution` redeployment with security fixes; round-aligned atomic cutover; single redistributor; stake and batches untouched | — | +| 1 | Surgical `Redistribution` redeployment with security fixes; round-aligned cutover; singleton redistributor *by operational discipline* (one role holder; the type-level guarantee lands in stage 5) | — | | 2 | F1 adopted as standing practice: new `Redistribution` on every breaking wire release | — | | 3 | `Cutover` contract and client support (F2, F3, F6, F7). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | | 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | -| 5 | `PostageAccounting` + `PostagePolicy`. Final batch migration. `copyBatch` retired | 4 | -| 6 | Governing multisig scope reduced to policy pointers only | 5 | +| 5 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis; `copyBatch` retired | 4 | +| 6 | Governing multisig scope reduced to policy pointers | 5 | -Stage 1 addresses measured harm and is the immediate next upgrade. Stage 2 is a process -decision available today at no cost. Stages 4 and 5 are where the custody property lands. -After stage 5, surgical redeployment and the absence of admin power over deposits coexist — -the two things currently treated as mutually exclusive. +Stage 1 addresses measured harm and is the immediate next upgrade. Stage 2 is available +today at no cost. Stages 4 and 5 are where the custody property lands. After stage 5, +surgical redeployment and the absence of admin power over deposits coexist — the two things +currently treated as mutually exclusive. ## Open questions 1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` - (suggested: aligned with the current freeze horizon), `MAX_SLASH_PER_WINDOW`, + (MUST be ≥ the maximum freeze horizon, `penaltyMultiplier * ROUND_LENGTH * 2^depth` at + plausible depths), `EXECUTION_WINDOW`, `CUTOVER_NOTICE`, `MAX_SLASH_PER_WINDOW`, `SLASH_WINDOW`, `MAX_POT_FRACTION_PER_WINDOW`, `CLAIM_WINDOW`, `MAX_PRICE`, - `MAX_PRICE_CHANGE_PER_UPDATE`, `CUTOVER_NOTICE`. These are immutable once deployed and so need their own analysis. -2. **Postage exit economics.** What forfeit fraction or minimum batch age makes - `refundBatch` non-abusable without making it useless as an escape hatch? -3. **Stranded pot.** Where does the residual pot in a retired core go, given that by - construction no one can direct it to an arbitrary address? -4. **Wind-down funding.** Should the F5 reduced-rate window be pre-funded from the treasury, - or is a fixed fraction of the live pot acceptable? -5. **Tail of the final batch migration.** Is a deadline-limited, deposit-matched - admin-assisted path acceptable, or must the final migration be fully user-driven even at - the cost of abandoning some batches? -6. **Multi-client discipline.** F2, F3 and F6 assume every client implements cutover - identically. - What is the conformance mechanism if a second client exists? -7. **Shared-account slashing.** Under C3.2, how is a slash apportioned when one account - backs several nodes — per-node sub-allocations, or a coverage requirement on the - account's deposit? -8. **Frozen outpayment model.** The accumulator in the core freezes linear per-block - accrual (C2.7). Is that the model we want to commit to indefinitely, and if not, what - is the minimal generalisation worth freezing instead? + `MAX_PRICE_CHANGE_PER_UPDATE` (MUST be compatible with `PriceOracle`'s adjustment + steps). Immutable once deployed; each needs a written derivation, not a suggestion. +2. **Refund economics.** The `refundBatch` forfeit fraction, and the wind-down decay + schedule (F5). A forfeit fraction is preferred over a minimum batch age: age penalises + exactly the long-lived honest batches C2.6 exists for. +3. **Treasury float.** The genesis migration requires the treasury to front the full + remaining batch value and be reimbursed over the longest batch life. What float is + acceptable, and should a deadline cap the reimbursement tail? +4. **Multi-client discipline.** F2, F3 and F6 assume every client implements cutover + identically. What is the conformance mechanism if a second client exists? +5. **Frozen outpayment model.** The accumulator in the core freezes linear per-block + accrual (C2.7). The considered alternative is to stop denominating in a global + accumulator: store each batch's remaining BZZ explicitly and let policy consume it into + the pot under a C2.4 rate cap. That keeps pricing-model changes policy-side and lets + users refund under a hostile consumption schedule, at the cost of a larger core and a + harder expiry index. Is linear accrual the model to commit to indefinitely, or is + remaining-BZZ-plus-capped-consumption the better thing to freeze? ## References - [`ethersphere/storage-incentives#310`][pr310] — Versioned Registry Router + Upgradeable Proxies for All Core Contracts, and the review discussion that motivated this SWIP. - *Forking Swarm: A migration guide* — Andrew Macpherson, Shtuka Research (presentation, - 2026). Not yet published at a stable URL; to be linked or mirrored - under `SWIPs/assets/swip-67/` with the author's consent. + 2026). Not yet published at a stable URL; to be mirrored under `SWIPs/assets/swip-67/` + with the author's consent before this SWIP leaves Draft. - Deployed contracts referenced throughout: `src/PostageStamp.sol`, `src/Staking.sol`, `src/Redistribution.sol` in `ethersphere/storage-incentives`. @@ -849,17 +891,19 @@ the two things currently treated as mutually exclusive. ## Acknowledgements Part 2 is substantially derived from Andrew Macpherson's *Forking Swarm* presentation -(Shtuka Research) and from his review of [`storage-incentives#310`][pr310]. Specifically -his: the fork and fork-migration framing (F0); the argument that contract identity rather -than wire version is what partitions the incentive game, and hence F1; the v2.8.0 dissent -measurements and the v0.9.3/v0.9.4 case study; the assessment that upgradeable staking is a -strict increase in attack surface, from burn to steal; and the observation that the interval -between a client release and the pausing of the old stake registry is dead time for -upgraded operators. - -Review of the first draft materially changed Part 1. Mark Bliss established that the -outpayment accumulator cannot live in the replaceable half, and why (C2.7), and supplied the -dual-ABI maintenance argument that F7.1 answers. (GitHub handle to be added.) +(Shtuka Research) and his review of [`storage-incentives#310`][pr310]: the fork and +fork-migration framing (F0); the argument that contract identity, not wire version, +partitions the incentive game (F1); the v2.8.0 dissent measurements and the v0.9.3/v0.9.4 +case study; the assessment that upgradeable staking is a strict increase in attack surface; +and the observation that the interval between a client release and the pausing of the old +registry is dead time for upgraded operators. + +Review of the first draft materially changed Part 1: the outpayment accumulator cannot live +in the replaceable half (C2.7), and the dual-ABI maintenance argument answered by F7.1 +(Mark Bliss; GitHub handle to be added). A subsequent review pass established that batch +sizes and the expiry ordering must also be core-side for C2.2 to be enforceable, that +`PostageStamp` has no deposit-extraction path — forcing the treasury-matched genesis design +— and the F3 execution-window form of cutover. This SWIP departs from *Forking Swarm* on one conclusion, set out in [Motivation](#motivation). Co-authorship is listed on the strength of the derived material; From 6c2ce28c24997159a30be56705e4fefdddf92d4a Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 00:44:26 +0200 Subject: [PATCH 07/24] swip-67: drop process placeholders from references and acknowledgements --- SWIPs/swip-67.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 09c184fc..bf0339b4 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -881,8 +881,7 @@ currently treated as mutually exclusive. - [`ethersphere/storage-incentives#310`][pr310] — Versioned Registry Router + Upgradeable Proxies for All Core Contracts, and the review discussion that motivated this SWIP. - *Forking Swarm: A migration guide* — Andrew Macpherson, Shtuka Research (presentation, - 2026). Not yet published at a stable URL; to be mirrored under `SWIPs/assets/swip-67/` - with the author's consent before this SWIP leaves Draft. + 2026). - Deployed contracts referenced throughout: `src/PostageStamp.sol`, `src/Staking.sol`, `src/Redistribution.sol` in `ethersphere/storage-incentives`. @@ -900,14 +899,13 @@ registry is dead time for upgraded operators. Review of the first draft materially changed Part 1: the outpayment accumulator cannot live in the replaceable half (C2.7), and the dual-ABI maintenance argument answered by F7.1 -(Mark Bliss; GitHub handle to be added). A subsequent review pass established that batch -sizes and the expiry ordering must also be core-side for C2.2 to be enforceable, that -`PostageStamp` has no deposit-extraction path — forcing the treasury-matched genesis design -— and the F3 execution-window form of cutover. +(Mark Bliss). A subsequent review pass established that batch sizes and the expiry +ordering must also be core-side for C2.2 to be enforceable, that `PostageStamp` has no +deposit-extraction path — forcing the treasury-matched genesis design — and the F3 +execution-window form of cutover. This SWIP departs from *Forking Swarm* on one conclusion, set out in -[Motivation](#motivation). Co-authorship is listed on the strength of the derived material; -@awmacpherson should feel free to ask for their name to be removed. +[Motivation](#motivation). ## Copyright From b33e5150faf7145f89fe1c96215e95317aeb5ba4 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 16:03:05 +0200 Subject: [PATCH 08/24] swip-67: name the procedure cutover, and split only postage and staking --- SWIPs/swip-67.md | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index bf0339b4..8e226300 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -1,6 +1,6 @@ --- SWIP: 67 -title: Custody separation and fork migration +title: Custody separation and cutover author: Cardinal (@0xCardiE), Andrew Macpherson (@awmacpherson) discussions-to: https://github.com/ethersphere/SWIPs/pull/108 status: Draft @@ -10,7 +10,7 @@ created: 2026-09-07 --- +funds, and specifies the cutover protocol that replacement runs under. --> ## Contents @@ -19,7 +19,7 @@ funds, and specifies the fork-migration protocol that replacement runs under. -- - [Specification](#specification) - [Key normative requirements at a glance](#key-normative-requirements-at-a-glance) - [Part 1 — Custody separation](#part-1--custody-separation) (C1–C5) - - [Part 2 — Fork migration](#part-2--fork-migration) (F0–F8) + - [Part 2 — Cutover](#part-2--cutover) (F0–F8) - [Rationale](#rationale) - [Backwards compatibility](#backwards-compatibility) — the two final migrations - [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) @@ -36,11 +36,18 @@ place. That single fact causes both of our recurring problems: - **Migrations.** When we refuse to use those powers, we must instead move everyone's money — a fund movement for every user and every operator, every time the rules change. -This SWIP splits each contract in two. A **core** holds the money, has no admin power over -it, is never upgraded, and enforces its own accounting invariants. A **policy** holds the -rules, is freely replaceable, and can never name a payment destination. It then specifies -the **fork-migration protocol** — how a new policy and a new Redistribution contract are -cut over at a round boundary, so that a protocol upgrade stops being a fund movement. +This SWIP splits the two fund-holding contracts — `PostageStamp` and `StakeRegistry` — +each into a **core** and a **policy**. The core holds the money, has no admin power over +it, is never upgraded, and enforces its own accounting invariants. The policy holds the +rules, is freely replaceable, and can never name a payment destination. + +`Redistribution` and `PriceOracle` hold no user deposits, so they are not split. They stay +replaceable contracts and are redeployed as-is: a new `Redistribution` on every breaking +wire release, so forked networks do not share one game; `PriceOracle` whenever its +adjustment rules change. + +It then specifies the **cutover protocol** — how clients switch to a new policy and a new +`Redistribution` at a round boundary, so a protocol upgrade stops being a fund movement. ## Abstract @@ -55,7 +62,7 @@ a timelock they enforce themselves; they never call into policy; and they offer permissionless exit that no role can pause. Policies hold batch admissibility, price submission, overlay derivation, commitment and effective-stake maths, and slashing rules. -**Part 2 — Fork migration.** Every breaking wire-protocol release MUST be accompanied by a +**Part 2 — Cutover.** Every breaking wire-protocol release MUST be accompanied by a new `Redistribution` deployment, even when its code is unchanged, so that the two branches of the resulting network fork do not play the same redistribution game. Cutover is signalled on chain by a `Cutover` contract that publishes *timing only*; contract addresses @@ -209,16 +216,18 @@ RFC 2119. #### C1. Structure -Each fund-holding contract is split into two deployed contracts. +The two fund-holding contracts, `PostageStamp` and `StakeRegistry`, are each split into a +frozen core and a replaceable policy. | Core (frozen, holds BZZ, no admin power over held funds) | Policy (replaceable, holds no user deposits) | |---|---| | `PostageAccounting` — batch ownership, depth, per-batch normalised balance, the outpayment accumulator, valid-chunk count, expiry ordering, pot | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price submission | | `StakingCore` — per-account deposit, first-deposit block, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | -`Redistribution` and `PriceOracle` are policy-class contracts: they hold no user deposits -and are plain redeployments, never proxies (see Part 2). `Redistribution` MAY transiently -hold pot funds between `claimPot` and winner payout (C4). +`Redistribution` and `PriceOracle` are not split. They hold no user deposits, so they are +policy-class contracts: plain redeployments, never proxies (see Part 2). `Redistribution` +MAY transiently hold pot funds between `claimPot` and winner payout (C4). `PriceOracle` is +redeployed when its adjustment rules change. Cores MUST NOT be deployed behind a proxy and MUST NOT contain `delegatecall`. Policies MAY be proxied or plainly redeployed; C2 makes the choice non-custodial, and given F1 plain @@ -536,7 +545,7 @@ redistributor, and misdirecting future rewards after `POLICY_TIMELOCK`. **Custod separation protects deposits, not rewards.** Bounding reward direction further would require freezing redistribution verification itself, which conflicts with F1. -### Part 2 — Fork migration +### Part 2 — Cutover #### F0. Definitions @@ -545,6 +554,11 @@ subset of the first's — canonically, of the stamp set. A **fork-migration** is which the old branch is intended to be wound down. Every breaking change to the Swarm wire protocol to date has been a fork-migration. +The **cutover protocol** is how this SWIP runs a fork-migration on the incentive contracts: +timing on chain, addresses in the client, authority change at a round boundary. It is +specified by F1–F8. It is not itself a fork, and it is also used for Type B (contract-only) +releases that do not fork the network. + A **breaking wire release** is a client release whose peer-negotiated protocol version differs from its predecessor's, so that mismatched peers disconnect and at least two disjoint p2p networks result. From 7b7dddaa7ad068b0d33c54daa261530e503b2125 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 16:14:39 +0200 Subject: [PATCH 09/24] swip-67: restructure around the four contracts; drop history, RFC, and back matter --- SWIPs/swip-67.md | 1064 +++++++++++++--------------------------------- 1 file changed, 295 insertions(+), 769 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 8e226300..5e9b5ee1 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -17,13 +17,13 @@ funds, and specifies the cutover protocol that replacement runs under. --> - [Simple Summary](#simple-summary) · [Abstract](#abstract) - [Motivation](#motivation) - [Specification](#specification) - - [Key normative requirements at a glance](#key-normative-requirements-at-a-glance) - - [Part 1 — Custody separation](#part-1--custody-separation) (C1–C5) - - [Part 2 — Cutover](#part-2--cutover) (F0–F8) + - [Redistribution](#redistribution) + - [Staking](#staking) + - [PostageStamp](#postagestamp) + - [PriceOracle](#priceoracle) + - [Cutover protocol](#cutover-protocol) - [Rationale](#rationale) -- [Backwards compatibility](#backwards-compatibility) — the two final migrations - [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) -- [References](#references) · [Acknowledgements](#acknowledgements) ## Simple Summary @@ -51,448 +51,206 @@ It then specifies the **cutover protocol** — how clients switch to a new polic ## Abstract -**Part 1 — Custody separation.** `PostageStamp` and `StakeRegistry` are each split into a -frozen custody core (`PostageAccounting`, `StakingCore`) and a replaceable policy contract -(`PostagePolicy`, `StakingPolicy`). - -Cores hold all BZZ. They expose no function that transfers to a caller-supplied address; -they enforce token conservation against their own records; they rate-limit every -value-moving primitive a policy can trigger; they change their policy pointer only through -a timelock they enforce themselves; they never call into policy; and they offer a -permissionless exit that no role can pause. Policies hold batch admissibility, price -submission, overlay derivation, commitment and effective-stake maths, and slashing rules. - -**Part 2 — Cutover.** Every breaking wire-protocol release MUST be accompanied by a -new `Redistribution` deployment, even when its code is unchanged, so that the two branches -of the resulting network fork do not play the same redistribution game. Cutover is -signalled on chain by a `Cutover` contract that publishes *timing only*; contract addresses -are carried in the client binary. Cutover MUST land on a round boundary and execute within -a bounded window. `PostageAccounting` enforces at most one authorised redistributor at any -block. - -Together the parts remove admin custody of deposits, bound admin influence over future -rewards, and reduce a protocol upgrade from "everyone moves their money" to "clients point -at a new policy address". +The suite is treated contract by contract. -## Motivation +- **`Redistribution`** is not split. Every breaking wire release deploys a new one, even + when bytecode is unchanged. Cutover lands on a round boundary; at most one redistributor + is authorised at any block. +- **`StakeRegistry`** splits into `StakingCore` (deposits, exits) and `StakingPolicy` + (overlay, height, effective stake, slash/freeze rules). Operators migrate stake once, + then deposits stay put across later forks. +- **`PostageStamp`** splits into `PostageAccounting` (balances, accumulator, pot, expiry + ordering) and `PostagePolicy` (admissibility, depth rules, price submission). Batches + are seeded once, treasury-matched; after that they carry across forks. +- **`PriceOracle`** is not split. It is redeployed when adjustment rules change, and + submits prices through `PostagePolicy` into the core's bounded `setPrice`. + +Cores hold all user BZZ. No core function transfers to a caller-supplied address. Pointers +change only after a core-enforced timelock. Exits cannot be paused. A `Cutover` contract +publishes *timing only*; contract addresses are compiled into the client. -### The two problems are one problem +## Motivation -Two threads have been running in parallel: an upgradeability thread -([`storage-incentives#310`][pr310]) and a migration thread (*Forking Swarm*). They are the -same problem seen from two sides. Because state and logic live in the same contract, -replacing logic means replacing state; replacing state means a migration; avoiding the -migration means giving an admin a shortcut over state — which is a power over funds. So we -oscillate between two bad options: +State and logic live in the same contract, so replacing logic means replacing state; +replacing state means a migration; avoiding the migration means giving an admin a shortcut +over state — which is a power over funds. We oscillate between two bad options: 1. **Use the admin shortcut.** Cheap, but the admin can steal the pot and burn all stake. 2. **Do a full redeployment and migrate everything.** Rug-resistant, but every logic change becomes a fund movement for every user and operator, and no batch migration has ever been completed without admin-driven cloning. -*Forking Swarm* concludes that phasing out admin powers makes surgical redeployment -impossible, so every upgrade must become a full-suite redeployment with batch and stake -migration. That is true of the *current* architecture but is a consequence of the -coupling, not of the threat model. Break the coupling and both options improve at once. - -### Where the custody surface is, in code - -These are properties of the deployed contracts as of writing. - -**`PostageStamp.withdraw(address beneficiary)`** is gated on `REDISTRIBUTOR_ROLE` and -transfers the whole of `totalPot()` to a caller-supplied address. - -**`REDISTRIBUTOR_ROLE` is an OpenZeppelin `AccessControl` role**, so any number of -addresses can hold it simultaneously and `DEFAULT_ADMIN_ROLE` can grant it. Two were in -fact authorised at once during the v0.9.3/v0.9.4 rollout (see below). - -**`PostageStamp.copyBatch` and `copyBatchBulk`** are gated on `DEFAULT_ADMIN_ROLE` and -create batch state — owner, depth, `normalisedBalance` — while incrementing -`validChunkCount`, **without transferring any BZZ into the contract**. `totalPot()` returns -`min(pot, balance)`, so unbacked state cannot directly over-transfer; but unbacked chunks -accrue pot at the same rate as paid ones, against the deposits of real batch owners. - -**`PostageStamp` has no path that returns an unexpired batch deposit to anyone.** The only -outflow is `withdraw`, and it moves the pot, never batch balances. Remaining prepaid -storage is locked until it expires into the pot. This is why every batch migration to date -has used `copyBatch`: the funds for an honest re-purchase cannot be extracted. Any -migration plan that bans unbacked minting must therefore also say where the backing BZZ -comes from — see [Backwards compatibility](#backwards-compatibility). - -**`StakeRegistry` is, by contrast, rug-resistant today.** No code path sends BZZ anywhere -except back to `msg.sender` (`withdrawFromStake`, `migrateStake`), and `slashDeposit` only -decrements the record, so slashed BZZ is burnt in place rather than stolen. Any change must -preserve this: making `StakeRegistry` upgradeable in the ordinary sense would be a strict -increase in attack surface, from "burn" to "steal". - -**The existing escape hatch does not survive its own threat model.** -`StakeRegistry.migrateStake()` is `whenPaused`, and `pause()` requires `DEFAULT_ADMIN_ROLE` -(the contract declares no `PAUSER_ROLE`; the `OnlyPauser()` error name is misleading). In -the scenario the hatch exists for — the admin is the adversary — the hatch stays shut -unless the adversary opens it. - -### What has gone wrong - -From *Forking Swarm*: - -- **Wire-only fork, v2.8.0 (2026-05-26).** A breaking wire-protocol change shipped without - a new `Redistribution`. Rounds with a dissenting reveal went from approximately zero per - week to approximately twenty; 2.8% of rounds in the first week; 44 distinct dissenting - identities; nine rounds in three weeks (0.38%) in which a dissenter was leader. In round - 306865 a dissenter revealed depth 10, so nodes were frozen for longer and the depth floor - blocked all nodes from the following round. -- **Staggered surgical redeployment, v0.9.3/v0.9.4 (2025).** Two redistributors were - authorised on the same `PostageStamp` at once for three weeks, and the resulting race - bled roughly 15 BZZ from operators on the production branch. Separately, the pausing of - the old stake registry was scheduled well after the corresponding client release, so - operators who had upgraded were unable to earn until it happened. - -Neither is evidence that migration is inherently slow or expensive. Both are scheduling -failures: overlapping authority that should have been singleton, and a cutover that was -staggered when it should have been atomic. F3 and F4 remove both by construction. The -structural point worth keeping is that **the interval between a client release and the -pausing of the old registry is dead time for everyone who has upgraded**; the remedy is to -make the interval zero by construction. - -### Why not put everything behind proxies - -[`storage-incentives#310`][pr310] proposes upgradeable proxies for all core contracts plus -an on-chain versioned registry, a registry-guarded proxy, and a `pinnedExecute` path. The -objections raised in its review hold, and this SWIP is the alternative: - -- A proxy over a fund-holding contract hands the proxy admin the ability to steal those - funds. -- Verifying the registry inside the proxy fallback taxes every user call and introduces a - liveness hazard: a mistaken deprecation or codehash mismatch reverts *all* user calls, - including withdrawals. -- `pinnedExecute` imposes a permanent selector-collision constraint on every future - implementation ABI and adds a second delegatecall path parallel to the fallback. -- The machinery solves "the admin swapped the implementation under me". If user funds live - in a contract that cannot be swapped, that event is no longer a fund-loss event, and the - machinery is not needed. - -This SWIP keeps a registry-like contract for the job it is good at — signalling cutover -timing (F2) — and drops the guarded proxy and `pinnedExecute`. - -### What this SWIP does not claim - -Custody separation removes the *on-chain* cost of a migration. It does not remove the fork -itself. Per-batch bucket counters, stamp validity as seen by nodes, and chunk availability -are off-chain, per-branch state, and they still partition on a wire-protocol change exactly -as described in *Forking Swarm*. Batches carry across a fork unchanged under this proposal; -the stamp set still forks. What disappears is the coordination tax that made forks -expensive enough to avoid. - -## Specification - -The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as in -RFC 2119. - -### Key normative requirements at a glance - -| | Requirement | -|---|---| -| **C1** | Each fund-holding contract splits into a frozen core and a replaceable policy. Cores are never deployed behind a proxy and contain no `delegatecall`. | -| **C2.1** | No core function transfers to a caller-supplied address. Every destination derives from core state. | -| **C2.2** | The core enforces token conservation itself, incrementally, on every call, including pot accrual over live chunks. | -| **C2.3** | Calls go policy → core only. No callbacks, no core reads of policy, no dependence of core correctness on policy code. | -| **C2.4** | The core rate-limits every value-moving primitive policy can trigger. | -| **C2.5** | Pointers change only after a timelock the core enforces with an immutable constant; a pending change is cancellable, never extendable. | -| **C2.6** | Each core offers an exit with no role check, no pause, and no dependence on policy state. | -| **C2.7** | Cores have no upgrade path, so they MUST stay minimal. The outpayment accumulator, batch sizes, and the expiry ordering live in the core; the outpayment model is thereby frozen. | -| **C3.1** | Participation eligibility counts from `min(firstDepositBlock, preRegistrationBlock)`; the core records `firstDepositBlock`. | -| **C3.2** | Deposits are recorded per account. An account's deposit MUST cover the sum of committed stakes of the nodes it backs. | -| **C4** | Batch creation is policy-gated; ids bind to the originating account; `claimPot` takes no destination; `expire` is permissionless and self-verifying; `setPrice` is bounded. | -| **F1** | Every breaking wire release deploys a new `Redistribution`, even if the bytecode is unchanged. | -| **F2.1** | The chain signal carries timing. Contract addresses are compiled into the client. | -| **F2.2** | Clients determine activation by observing `Cutover` state, never by a height baked into the binary. | -| **F3** | `activationBlock` falls on a round boundary of the outgoing game, and authority changes execute within a bounded window from it. | -| **F4** | At most one redistributor is authorised at any block, enforced by type rather than by role hygiene. | -| **F5** | Any old-branch wind-down is pre-funded before cutover; a retired `Redistribution` never regains pot access. | -| **F6** | Clients read timing from chain, addresses from the binary, and send no fund-moving transaction in response to a chain signal. | -| **F7.1** | A cutover needing a runtime branch in consensus-critical computation is wire-breaking, and ships a single game ABI. | - -### Part 1 — Custody separation - -#### C1. Structure - -The two fund-holding contracts, `PostageStamp` and `StakeRegistry`, are each split into a -frozen core and a replaceable policy. - -| Core (frozen, holds BZZ, no admin power over held funds) | Policy (replaceable, holds no user deposits) | -|---|---| -| `PostageAccounting` — batch ownership, depth, per-batch normalised balance, the outpayment accumulator, valid-chunk count, expiry ordering, pot | `PostagePolicy` — batch admissibility, depth and bucket rules, minimum balances, price submission | -| `StakingCore` — per-account deposit, first-deposit block, withdrawal accounting | `StakingPolicy` — overlay derivation, height, committed stake, effective stake, freeze and slash rules | - -`Redistribution` and `PriceOracle` are not split. They hold no user deposits, so they are -policy-class contracts: plain redeployments, never proxies (see Part 2). `Redistribution` -MAY transiently hold pot funds between `claimPot` and winner payout (C4). `PriceOracle` is -redeployed when its adjustment rules change. - -Cores MUST NOT be deployed behind a proxy and MUST NOT contain `delegatecall`. Policies MAY -be proxied or plainly redeployed; C2 makes the choice non-custodial, and given F1 plain -redeployment is expected in practice. - -#### C2. Core invariants - -The split is not the security property; the invariants are. The current architecture -already has the shape "frozen ledger, swappable policy" — a `PostageStamp` that never -changes with a replaceable `Redistribution` authorised on it — and it leaks full custody -anyway, because `withdraw(beneficiary)` is an unconstrained primitive. - -**C2.1 — No caller-supplied destinations.** A core MUST NOT transfer tokens to an address -supplied by the caller or by policy. Every destination MUST be derived from the core's own -recorded state: - -- `PostageAccounting.refundBatch(batchId)` pays `ownerOf(batchId)`. -- `StakingCore.withdraw(amount)` pays `msg.sender`. -- `PostageAccounting.claimPot(amount)` pays the single authorised redistributor address, - which is itself set only via C2.5. - -**C2.2 — Conservation, enforced by the core.** Each core MUST maintain, checked at the end -of every state-changing call: - -``` -pot + sum(remaining batch claims) <= token.balanceOf(core) (PostageAccounting) -totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore) -``` +That deadlock is a consequence of the coupling, not of the threat model. Break the +coupling and both options improve at once. -maintained incrementally, never by iterating balances. For `PostageAccounting` this -requires the core to own the accrual identity that today lives in -`PostageStamp.expireLimited`: +**Where the money can move today.** +`PostageStamp.withdraw(address beneficiary)` is gated on `REDISTRIBUTOR_ROLE` and sends +the whole pot to a caller-supplied address. That role is an OpenZeppelin `AccessControl` +role, so any number of addresses can hold it at once. `copyBatch` / `copyBatchBulk` mint +batch state without transferring BZZ in. `PostageStamp` has **no path that returns an +unexpired batch deposit** — the only outflow is `withdraw`, and it moves the pot, never +remaining balances. That is why every batch migration to date used `copyBatch`. -- the core records each batch's **depth** at creation, and maintains `validChunkCount` - and `lastExpiryBalance` as its own aggregates; -- before any `claimPot` and before conservation is checked, accrued outpayment MUST be - settled: expired batches contribute `batchSize * (normalisedBalance - - lastExpiryBalance)`, live chunks contribute `validChunkCount * (currentTotalOutPayment() - - lastExpiryBalance)`; -- settlement of live-chunk accrual MUST NOT run while an expired batch is still counted in - `validChunkCount`, since that would credit the pot beyond the batch's backing. +`StakeRegistry` is rug-resistant: BZZ only goes back to `msg.sender`, and slashing burns +in place. Making it upgradeable in the ordinary sense would be a strict increase in +attack surface, from burn to steal. Its escape hatch, `migrateStake()`, is `whenPaused`, +and `pause()` requires `DEFAULT_ADMIN_ROLE` — so against the admin it is not an escape +hatch. -The last bullet is why the expiry **ordering structure stays in the core** (C2.7): the core -can only know that no expired batch remains counted by knowing the minimum normalised -balance. An ordering index in policy would make conservation depend on policy honesty, -violating C2.3. +Custody separation removes the on-chain cost of a migration. It does not remove the fork: +bucket counters and chunk availability still partition on a wire change. Batches carry +across; the stamp set still forks. -Unbacked batch creation (`copyBatch`) is precisely a breach of the first inequality, and -under C2.2 no policy — honest, buggy, or malicious — can reproduce it. - -**C2.3 — One-way calls.** Calls MUST go policy → core only. A core MUST NOT call, delegate -to, or read from its policy, and MUST NOT expose callbacks or hooks. A corollary: a core -cannot ask policy whether an action is permitted; every check a core performs is -self-contained. - -**C2.4 — Bounded authority.** Every value-moving primitive a policy can trigger MUST be -rate-limited by the core: +## Specification -| Primitive | Bound | +Shared rules for the two cores (`StakingCore`, `PostageAccounting`): + +- No proxy, no `delegatecall`. +- No transfer to a caller-supplied address. Destinations come from core state: + `refundBatch` pays the recorded owner; `withdraw`/`exit` pay `msg.sender`; `claimPot` + pays the single authorised redistributor. +- Conservation is checked incrementally on every state-changing call: + `pot + sum(remaining claims) <= balance` on postage; `totalDeposited - totalWithdrawn + <= balance` on staking (slashed BZZ stays in the contract). +- Calls go policy → core only. The core never reads policy. +- Value-moving primitives policy can trigger are rate-limited by immutable core windows: + `claimPot`, `slash`, `setPrice`. +- Policy and redistributor pointers change only after `POLICY_TIMELOCK`, enforced by the + core; a pending change is cancellable. +- `exit` / `refundBatch` have no role check, no pause, and ignore policy locks. + +A core with a timelocked pointer is not admin-free. The claim is narrower: no privileged +operation can move a user's deposit, and every privileged operation is announced in +advance with an exit window. + +### Redistribution + +Not split. It holds no user deposits. It MAY transiently hold pot funds between +`claimPot` and winner payout; that hop SHOULD complete in one transaction. Stranded BZZ +there is governance-recoverable — protocol funds, not deposits. + +**State.** Commits, reveals, round counters and the last winner. None of it is worth +preserving across a release. Overlay, stake and freeze data live in staking; postage +balances live in postage. Redistribution only *reads* those and *calls* `claimPot` / +`slash` / `lock`. + +**How it is updated.** Every breaking wire release deploys a **new** `Redistribution`, +even if the bytecode is unchanged. Contract identity, not the wire version, is what +partitions the incentive game. Without a new contract, both branches of a wire fork play +the same game with divergent stamp-set views: stragglers claim payments meant for the +new branch, and honest nodes can be frozen for disagreeing with a non-upgraded leader. + +A contract-only bugfix (Type B) also deploys a new `Redistribution` and switches the +postage core's redistributor pointer; the wire protocol is unchanged and operators who +already run a dual-binding release keep earning. + +**Cutover.** Incoming `Redistribution` accepts commits from `activationBlock` onward. +The postage core authorises at most one redistributor address at a time. The pointer +moves through `proposeRedistributor` / `executeRedistributor` under the timelock and +only inside `[activationBlock, activationBlock + EXECUTION_WINDOW)`. Until execution, +the outgoing contract remains authorised, so a late execution shortens the first new +round rather than orphaning a committed node. `claimPot` reverts for any other caller. + +If the old branch still needs to pay for data availability, the outgoing +`Redistribution` MAY keep paying at a reduced, decaying rate. That pot MUST be moved +into the outgoing contract **before** cutover. After cutover it is never the authorised +pointer again. + +**Migration.** None. There is no user state to move. Clients switch the address they +call at `activationBlock`. Until `PostageAccounting` exists, singleton authority is +operational (one `REDISTRIBUTOR_ROLE` holder); the type-level guarantee lands with the +postage split. + +### Staking + +Split. `StakeRegistry` becomes `StakingCore` + `StakingPolicy`. + +| Stays in `StakingCore` (frozen, holds BZZ) | Moves to `StakingPolicy` (replaceable) | |---|---| -| `claimPot(amount)` | at most `MAX_POT_FRACTION_PER_WINDOW` of `pot` per `CLAIM_WINDOW` blocks | -| `slash(account, amount)` | at most `MAX_SLASH_PER_WINDOW` in aggregate per `SLASH_WINDOW` blocks | -| `setPrice(price)` | `price <= MAX_PRICE`, step from `lastPrice` at most `MAX_PRICE_CHANGE_PER_UPDATE` | - -The windows are core-owned block counts, not the redistribution game's round length, which -is per-branch and replaceable (F1). The parameters are immutable once set; values are an -open question. - -Be precise about what the pot bound buys. The honest game already pays the whole pot to a -winner every round, so a `claimPot` cap at or above the honest rate does not slow a -malicious redistributor below normal outflow — it caps *acceleration*. The protections -against a hostile policy are the C2.5 timelock (it cannot be installed silently) and the -C2.6 exit (users can leave during the announcement window); the C2.4 bounds exist so that -even an installed hostile policy cannot flash-drain what has accrued between exits. - -**C2.5 — Timelocked pointers, enforced by the core.** A core MAY allow its policy pointer -or its redistributor pointer to change, and if it does: - -- the change MUST be proposed and then executed no earlier than `POLICY_TIMELOCK` blocks - later, with both proposal and execution emitting events; -- the proposer (the governing address) MAY cancel a pending change at any time before - execution; cancellation MUST NOT extend or shorten any other pending change; -- the timelock MUST be enforced by the core itself, not by an external timelock contract a - role could replace, and `POLICY_TIMELOCK` MUST be immutable. - -A core with a timelocked pointer **has a privileged operation** and is not admin-free. The -claim is narrower and checkable: *no privileged operation can move a user's deposit, and -every privileged operation is announced in advance with a guaranteed exit window*. - -**C2.6 — Permissionless exit.** Each core MUST provide an exit that: - -- any principal can call for their own funds, with no role check; -- has no pause modifier and cannot be disabled by any role; -- does not route through any replaceable contract; -- ignores any lock set by policy when computing the exit amount, using only core-recorded - claims. - -Concretely: `StakingCore.exit()` returns the caller's recorded deposit, and -`PostageAccounting.refundBatch(batchId)` returns the batch's remaining balance to its -owner. `exit()` MUST be preceded by `requestExit()` and callable `EXIT_DELAY` blocks later -— a fixed unbonding period, not a role-gated pause — so it cannot be used to dodge -in-flight slashing. - -Two consequences are acknowledged rather than hidden: - -- **`exit()` is withdrawable stake.** Today's `StakeRegistry` only returns surplus above - the committed stake; a general unbonding exit is a change to staking economics, aligned - with the ongoing withdrawable-stake discussion, and `EXIT_DELAY` MUST be at least the - maximum freeze horizon the game can impose, or exit dodges penalties. -- **`refundBatch` changes the storage promise.** Today a batch balance is a commitment no - one can retract; under this SWIP a mutable batch is revocable mid-life. Nodes MUST treat - a refund event as batch invalidation (the same handling as expiry, on a new trigger), and - clients MUST observe refund events. Because stamp validity is consensus-adjacent, the - cutover that introduces `refundBatch` MUST be treated as Type A (F7). `refundBatch` - SHOULD forfeit a fixed fraction of the remaining balance to the pot so that - top-up/upload/refund is not free storage; the fraction is an open question. Immutable - batches (`immutableFlag`) are not refundable. - -**C2.7 — Frozen means frozen.** Cores have no upgrade path. This is the risk the proposal -takes on, and it MUST be managed by keeping cores minimal. A core with a bug and no admin -is worse than an upgradeable contract. Therefore: - -- Cores hold balances, ownership, batch depth, the outpayment accumulator, the expiry - ordering, the pointers and bounds their own invariants need, and the conservation check. -- Batch admissibility rules, effective-stake curves, commitment maths, overlay derivation - live in policy, where they can be fixed. -- Cores MUST be formally specified and MUST have full invariant and fuzz coverage before - deployment (see [Test cases](#test-cases)). - -The outpayment accumulator cannot live in the replaceable half. A batch's -`normalisedBalance` is denominated *in the accumulator of the contract that issued it*: - -``` -currentTotalOutPayment() = totalOutPayment + lastPrice * (block.number - lastUpdatedBlock) -remainingBalance(id) = max(0, normalisedBalance[id] - currentTotalOutPayment()) -``` - -A fresh contract starts the accumulator at zero, so every balance must be *rebased*, not -re-pointed — which is what today's `copyBatch` does. If the accumulator lived in policy, -every policy replacement would rebase every batch: wrong expiry and premature reserve -eviction, once per upgrade instead of once per migration. - -The cost is that **the outpayment model itself is frozen**: linear per-block accrual -against a per-chunk normalised balance. Moving to non-linear or per-neighbourhood pricing -is not a policy change and would still require a migration. This is the largest single -thing the proposal gives up; the one considered alternative is recorded in -[Open questions](#open-questions). - -The expiry ordering structure in the core is the second-largest C2.7 risk: it is the most -edge-case-heavy component in the current contract, and under this SWIP it becomes -unfixable. It stays in the core because C2.2 requires it (see above); the compensation is -the mandatory adversarial and differential test burden in [Test cases](#test-cases). - -#### C3. `StakingCore` interface +| Per-account deposit, `firstDepositBlock`, withdrawal and exit accounting | Overlay derivation, height, committed stake, effective stake, freeze and slash rules | -Staking is the easier case and SHOULD be done first: its only funds-out direction is -already "pay `msg.sender`", so C2.1 is satisfiable without changing any user's economics. +`StakingCore` MUST NOT store overlays, heights, committed stake or effective stake, and +MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so it is redeployed with a fork; +deposits are not. ```solidity interface IStakingCore { - // ---- user ---- - /// @notice Deposit BZZ. Credited to msg.sender. No policy call. - /// Records firstDepositBlock on the account's first deposit. function deposit(uint256 amount) external; - - /// @notice Withdraw up to `amount` of the caller's unlocked deposit. Pays msg.sender only. function withdraw(uint256 amount) external; - - /// @notice Permissionless exit (C2.6). Not pausable, ignores policy locks. - /// exit() callable EXIT_DELAY blocks after requestExit(). function requestExit() external; function exit() external; - - // ---- policy, bounded (C2.4) ---- - /// @notice Reduce a deposit. Burnt in place; never transferred out. - /// Reverts if the aggregate slash cap for the window is exceeded. function slash(address account, uint256 amount) external; - - // ---- policy, unbounded but exit-safe ---- - /// @notice Prevent withdraw() (but never exit()) for `until`. function lock(address account, uint64 until) external; - - // ---- views ---- function depositOf(address account) external view returns (uint256); function firstDepositBlock(address account) external view returns (uint64); function totalDeposited() external view returns (uint256); } ``` -`StakingCore` MUST NOT store overlays, heights, committed stake, or effective stake, and -MUST NOT read `PriceOracle`. Those are per-branch, consensus-critical values and belong in -`StakingPolicy`. Overlay derivation mixes `NetworkId`, so it is redeployed with a fork; -deposits are not. +`deposit` credits `msg.sender` and records `firstDepositBlock` on the first credit. No +policy call. `withdraw` pays `msg.sender` only, and `lock` can block it. `exit` cannot +be locked, paused, or routed through policy; it is callable `EXIT_DELAY` blocks after +`requestExit()`. `slash` burns in place and is capped per window. + +`exit()` is withdrawable stake. Today only surplus above committed stake can leave. +`EXIT_DELAY` MUST be at least the maximum freeze horizon the game can impose, or exit +dodges penalties. + +**Eligibility.** `StakingPolicy` computes participation from +`min(firstDepositBlock, preRegistrationBlock)`. Pre-registration is a zero-value +transaction an operator may send before a deposit or a cutover, so a mass restake does +not open a participation trough. -`StakingPolicy` SHOULD accept an immutable `predecessor` address and lazily inherit overlay -and height from it on first use, so a fork requires no operator transaction and no -eligibility delay. +**Accounts and nodes.** Deposits are per account; overlay mapping is policy-side. One +account may back several nodes. `StakingPolicy` MUST NOT admit overlays whose summed +committed stake exceeds the account's deposit. A slash reduces the account, and therefore +every overlay it backs. -**C3.1 — Eligibility clock.** `StakingPolicy` MUST compute participation eligibility from -`min(firstDepositBlock, preRegistrationBlock)`, where `firstDepositBlock` is the -core-recorded value above and pre-registration is a zero-value transaction an operator MAY -send in advance of a deposit or a cutover. +`StakingPolicy` SHOULD take an immutable `predecessor` and lazily inherit overlay and +height on first use, so a later fork needs no operator transaction. -`Redistribution` requires a stake record older than `2 * ROUND_LENGTH` before a node may -participate. Without a pre-registration clock, any event that makes many operators -establish stake records at similar times produces a rolling participation trough, during -which a single dissenter's chance of being leader rises sharply — the v2.8.0 failure mode, -self-inflicted. Staggering the event lengthens the trough rather than fixing it; -pre-registration lets the settling period elapse beforehand. +**Migration (once).** Operators move deposits with today's `migrateStake()` onto +`StakingCore`. The old registry is paused at `activationBlock`, not later. Operators +SHOULD pre-register so they are eligible immediately. After this, stake does not move +again: later forks only replace `StakingPolicy`. -**C3.2 — Accounts and nodes.** `StakingCore` records deposits per *account*; mapping an -account to one or more node overlays is `StakingPolicy`'s responsibility. Consequences: -fleet operations scale with accounts rather than nodes; withdrawal authority is separated -from the node's operational signer, so a compromised node key cannot move funds; and the -final stake migration's cost falls sharply. +**Cutover after the split.** Policy-pointer change on `StakingCore` under +`POLICY_TIMELOCK`. Deposits, withdrawals and exits never change ABI. -Shared-account slashing is resolved by a **coverage requirement**: `StakingPolicy` MUST NOT -admit a set of overlays for an account whose summed committed stake exceeds the account's -core-recorded deposit, and a slash reduces the account's deposit (and therefore, at the -policy layer, the eligibility of all overlays it backs). Per-node sub-allocations were -considered and rejected as policy-side complexity the core cannot verify. +### PostageStamp -#### C4. `PostageAccounting` interface +Split. `PostageStamp` becomes `PostageAccounting` + `PostagePolicy`. + +| Stays in `PostageAccounting` (frozen, holds BZZ) | Moves to `PostagePolicy` (replaceable) | +|---|---| +| Batch ownership, depth, normalised balance, outpayment accumulator, `validChunkCount`, expiry ordering, pot | Batch admissibility, bucket-depth rules, minimum balances, price submission | + +Depth and the expiry ordering stay in the core because conservation needs them. Pot +accrual is the same identity as today's `expireLimited`: expired batches contribute +`batchSize * (normalisedBalance - lastExpiryBalance)`; live chunks contribute +`validChunkCount * (currentTotalOutPayment() - lastExpiryBalance)`. Live-chunk accrual +MUST NOT settle while an expired batch is still counted — which is why the core, not +policy, owns the minimum-balance index. A policy-side accumulator would rebase every +batch on every policy replacement. + +The outpayment model is therefore frozen: linear per-block accrual against a per-chunk +normalised balance. A different model is not a policy change; it would need a new core. ```solidity interface IPostageAccounting { - // ---- policy-gated: batch admissibility lives in PostagePolicy ---- - /// @notice Create and fund a batch. The core derives the id from - /// (originator, nonce), records depth, transfers the total in from - /// the policy's caller, and credits the normalised balance. function fund( address originator, bytes32 nonce, address owner, uint8 depth, bool immutableFlag, uint256 amountPerChunk ) external returns (bytes32 batchId); - - /// @notice Change a batch's depth. The core preserves total remaining value, - /// recomputing the per-chunk balance and validChunkCount. function resize(bytes32 batchId, uint8 newDepth) external; - - /// @notice Submit a new price. The core folds it into its own accumulator. - /// Bounded by MAX_PRICE and MAX_PRICE_CHANGE_PER_UPDATE (C2.4). function setPrice(uint256 price) external; - - /// @notice Pay out to the single authorised redistributor. Capped per - /// window (C2.4). Destination is not a parameter. function claimPot(uint256 amount) external; - - // ---- user, direct on the core ---- - /// @notice Add funds to an existing batch. Owner and depth unchanged. function topUp(bytes32 batchId, uint256 amountPerChunk) external; - - /// @notice Owner-only exit, no role check (C2.6). Pays ownerOf(batchId). - /// Forfeits a fixed fraction to the pot. Reverts for immutable batches. function refundBatch(bytes32 batchId) external; - - /// @notice Retire batches whose remaining balance the core verifies as zero, - /// settling accrual per C2.2. Permissionless; ids are hints. function expire(bytes32[] calldata batchIds) external; - - // ---- redistributor pointer (C2.5, F4) ---- function proposeRedistributor(address next) external; function cancelRedistributor() external; function executeRedistributor() external; - - // ---- views ---- function remainingBalance(bytes32 batchId) external view returns (uint256); function normalisedBalanceOf(bytes32 batchId) external view returns (uint256); function depthOf(bytes32 batchId) external view returns (uint8); @@ -504,422 +262,190 @@ interface IPostageAccounting { } ``` -There is no `withdraw(address)` and no unbacked creation path. The redistributor pointer is -singleton by construction, which is the direct fix for the v0.9.3 double-redistributor -race. - -**Call topology.** `fund` and `resize` are policy-gated: admissibility (minimum balance, -bucket-depth rules, mutability) is checked in `PostagePolicy` before it forwards to the -core, so a dead or hostile policy can block *creation* — a liveness cost bounded by the -C2.5 timelock — but never block `topUp`, `refundBatch`, `expire`, or conservation, which -are direct on the core. Price submission flows `PriceOracle` → `PostagePolicy` → -`setPrice`, and the C2.4 price bounds MUST be compatible with the oracle's adjustment -steps. - -**Batch identity.** New batch ids MUST derive from `(originator, nonce)`, preserving -today's `keccak256(sender, nonce)` binding so an announced id cannot be front-run by a -third party. Ids not derived this way exist only as genesis-seeded state (see -[Backwards compatibility](#backwards-compatibility)); after genesis is sealed there is no -path that accepts an arbitrary id. - -**Pot custody in `claimPot`.** The winner payout becomes two hops: the core pays the -authorised `Redistribution`, which pays the winner. `Redistribution` therefore transiently -holds pot funds; its claim path SHOULD complete both hops in one transaction, and any BZZ -stranded in a `Redistribution` by a failed payout is governance-recoverable there — it is a -policy-class contract holding protocol funds, not user deposits. - -#### C5. Residual trust after Part 1 - -| Capability | Today | After Part 1 | -|---|---|---| -| Steal all staked BZZ | No (burn only) | No | -| Burn all staked BZZ | Yes (redistributor role) | No — capped per window (C2.4) | -| Steal the entire pot in one call | Yes (`withdraw(beneficiary)`) | No — no such primitive (C2.1) | -| Drain the pot over time | Yes | At most the honest payout rate, timelocked and announced (C2.4, C2.5) | -| Create unbacked batch state | Yes (`copyBatch`) | No (C2.2) | -| Misdirect *future* rewards | Yes | Yes, after `POLICY_TIMELOCK`, announced | -| Close the user escape hatch | Yes (`DEFAULT_ADMIN_ROLE`) | No (C2.6) | +There is no `withdraw(address)` and no unbacked creation path. + +`fund` and `resize` are policy-gated (admissibility). A dead policy can block new +batches, never `topUp`, `refundBatch`, `expire`, or conservation. New ids derive from +`(originator, nonce)`, same binding as today. Arbitrary ids exist only as genesis-seeded +state; after sealing there is no such path. + +`claimPot(amount)` pays the authorised redistributor, not a caller-supplied address, and +is capped per window. The cap limits *acceleration*; the honest game already pays the +whole pot each round. Protection against a hostile redistributor is the timelock plus +`refundBatch`. + +`refundBatch` pays the recorded owner and SHOULD forfeit a fraction to the pot. +Immutable batches are not refundable. Nodes treat a refund as batch invalidation, same +as expiry. Introducing `refundBatch` is Type A: stamp validity is consensus-adjacent. + +**Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired +deposits, so the new core is seeded and separately backed: + +1. At `activationBlock`, `PostageStamp` is paused (`createBatch`, `topUp`, + `increaseDepth` freeze; expiry and `withdraw` continue). +2. `PostageAccounting` is deployed in a genesis phase. The deployer seeds the batch set + as of `activationBlock` and transfers in matching BZZ for the full seeded value. The + treasury fronts this float. +3. Genesis is sealed in the same ceremony. Until sealed, no other call is accepted; + after sealing, no seeding path exists. +4. The treasury is reimbursed from the old contract as seeded batches' old-side balances + expire into the old pot, using `withdraw` with the treasury as beneficiary — the final + announced use of that primitive. The tail equals the longest remaining batch life. -Two capabilities survive: claiming the pot at up to the honest rate through a hostile -redistributor, and misdirecting future rewards after `POLICY_TIMELOCK`. **Custody -separation protects deposits, not rewards.** Bounding reward direction further would -require freezing redistribution verification itself, which conflicts with F1. +After this, batches do not migrate again. Later forks only replace `PostagePolicy` and +`Redistribution`. -### Part 2 — Cutover +**Cutover after the split.** Redistributor pointer as in [Redistribution](#redistribution). +Policy-pointer change under `POLICY_TIMELOCK`. Balance reads never change ABI. -#### F0. Definitions +**Residual trust.** Deposits cannot be stolen or flash-drained. A hostile policy can +still, after the timelock, claim the pot at up to the honest rate and bias who wins. +Custody separation protects deposits, not rewards. -A **fork** of the Swarm network is a second network whose initial state is a clone of a -subset of the first's — canonically, of the stamp set. A **fork-migration** is a fork in -which the old branch is intended to be wound down. Every breaking change to the Swarm wire -protocol to date has been a fork-migration. +### PriceOracle -The **cutover protocol** is how this SWIP runs a fork-migration on the incentive contracts: -timing on chain, addresses in the client, authority change at a round boundary. It is -specified by F1–F8. It is not itself a fork, and it is also used for Type B (contract-only) -releases that do not fork the network. +Not split. It holds no BZZ and no user state. -A **breaking wire release** is a client release whose peer-negotiated protocol version -differs from its predecessor's, so that mismatched peers disconnect and at least two -disjoint p2p networks result. +**State.** Current price, last adjusted round, redundancy targets, `changeRate` steps. +All of it is replaceable. The postage core stores `lastPrice` and the accumulator; the +oracle does not. -#### F1. A new `Redistribution` per breaking wire release +**How it is updated.** Redeployed when adjustment rules change (the rate table, the +redundancy target, the pause behaviour). A Type B cutover if the wire is unchanged; Type +A if a consensus-critical consumer would need a runtime branch. -Every breaking wire release MUST be accompanied by the deployment of a new `Redistribution` -contract, **even if its bytecode is unchanged**. +Price submission is `PriceOracle` → `PostagePolicy` → `PostageAccounting.setPrice`. The +core enforces `price <= MAX_PRICE` and a maximum step from `lastPrice`. Those bounds +MUST be compatible with the oracle's own steps, or honest adjustments revert. -Contract identity, not the wire version, is what partitions the incentive game. Without a -new `Redistribution`, both branches play the same game with divergent views of the stamp -set — a negative-sum outcome in which stragglers claim payments intended for the new -branch, upgraded nodes earn less, and honest nodes are frozen for disagreeing with a -non-upgraded leader. This is the measured v2.8.0 failure mode. +**Migration.** None. Clients switch the compiled oracle address at `activationBlock`. +In-flight postage balances are unaffected: they are denominated in the core accumulator, +not in the oracle. -`Redistribution` holds no state worth preserving, so this is close to free. It is the -cheapest recommendation in this SWIP and SHOULD be adopted as standing practice -independently of everything else here. +### Cutover protocol -#### F2. Cutover signalling: timing on chain, addresses in the binary +Shared by all four. A **fork** is a second network cloned from a subset of the first +(canonically the stamp set). A **breaking wire release** is a client whose peer protocol +version differs, so mismatched peers disconnect. -A `Cutover` contract publishes the schedule: +A `Cutover` contract publishes **when**. Addresses live in the client binary. ```solidity interface ICutover { struct Schedule { - uint32 wireVersion; // client protocol version this cutover activates - uint64 activationBlock; // MUST satisfy F3 alignment + uint32 wireVersion; + uint64 activationBlock; // round boundary of the outgoing game bytes32 manifest; // hash of the release's address set } - function schedule(uint32 wireVersion) external view returns (Schedule memory); function current() external view returns (Schedule memory); - - event CutoverScheduled(uint32 wireVersion, uint64 activationBlock, bytes32 manifest); - event CutoverExecuted(uint32 wireVersion, uint64 atBlock); } ``` -**F2.1 — The signal carries timing; the binary carries addresses.** A client MUST NOT learn -a contract address from the chain and act on it. Contract addresses MUST be compiled into -the client release. The `Cutover` contract may tell a client *when* to switch; it MUST NOT -be able to tell it *where*. The `manifest` field is a hash the client checks against its -own compiled address set, and a mismatch MUST be a hard failure. The alternative — a client -that reads a destination from chain and moves funds toward it — reproduces, inside the -client, exactly the admin power this SWIP removes from the contracts. - -**F2.2 — Schedules are event-driven, not height-hardcoded.** Clients MUST determine -activation by observing `Cutover` state, not by a height baked into the binary, so a -slipped date does not require an emergency release. A rescheduled `activationBlock` MUST be -re-announced at least `CUTOVER_NOTICE` blocks before the new activation. - -**F2.3 — `Cutover` governance.** Schedules are written by the governing multisig. The -`Cutover` contract holds no funds and no fund-moving authority, so its failure mode is -liveness, not custody: a hostile or absent scheduler can delay cutovers, never redirect -money. Scheduling and rescheduling MUST emit events, and a schedule inside its -`CUTOVER_NOTICE` window MUST NOT be modified — cancellation counts as rescheduling. - -#### F3. Round-aligned cutover with a bounded execution window - -`activationBlock` MUST fall on a round boundary **of the outgoing game**: -`activationBlock % ROUND_LENGTH_outgoing == 0`. A cutover landing mid-round orphans nodes -that have committed: they lose their reveal window and may be frozen for a phase violation -they did not cause. If a release changes `ROUND_LENGTH`, that change is Type A (F7), and -the incoming game starts at a boundary of the outgoing one. - -Exact-block execution cannot be demanded of a multisig, and "not before" (a bare timelock) -is not "at". Therefore: - -- `executeRedistributor()` MUST be valid only within - `[activationBlock, activationBlock + EXECUTION_WINDOW)` for the scheduled cutover, where - `EXECUTION_WINDOW` is a core constant well under one round; -- the incoming `Redistribution` MUST accept commits from `activationBlock` onward; -- until execution, the outgoing redistributor remains authorised, so a late execution - inside the window shortens the first new round's claim rather than orphaning anyone. - -A gap in redistributor coverage and an orphaned round are distinct failures; the rules -above prevent both without demanding single-block inclusion. - -#### F4. Exactly one redistributor, by construction - -`PostageAccounting` MUST authorise at most one redistributor address at any block. The -pointer changes only through `proposeRedistributor` / `executeRedistributor` under -`POLICY_TIMELOCK` (C2.5) and the F3 window, and `claimPot` reverts for any caller that is -not the current pointer. - -This replaces `REDISTRIBUTOR_ROLE`, under which multiple simultaneous holders are -representable and were in fact simultaneously authorised in 2025. Here singleton authority -is a property of the type, not of operational discipline. (Until `PostageAccounting` -exists, F4 can only be honoured operationally — see stage 1 in -[Implementation](#implementation).) - -#### F5. Old-branch wind-down - -Immediately zeroing rewards on the old branch is correct for incentive alignment and wrong -for data availability: old-branch data stays retrievable only while old-branch nodes stay -online. - -Where a fork leaves user-side action with a tail, the schedule MAY include a wind-down -window during which the outgoing `Redistribution` continues paying at a reduced, decaying -rate. Its funding MUST be transferred into the outgoing `Redistribution` **before** -cutover — from the treasury or from a final pre-cutover `claimPot` — because after -cutover the retired contract is no longer the authorised pointer and MUST NOT regain pot -access. This is a payment overlap, never an authority overlap; F4 is not relaxed. Funds -left in a retired `Redistribution` after wind-down are governance-recoverable (it is -policy-class and holds no user deposits). - -#### F6. Client requirements - -A conforming client: - -1. MUST compile in the full address set for each protocol version it supports, and the - `manifest` hash for each. -2. MUST read `Cutover` for timing only, and MUST hard-fail on `manifest` mismatch (F2.1). -3. MUST switch the `Redistribution` address it uses at `activationBlock`, not when the - operator restarts. -4. MUST NOT send any fund-moving transaction as an automated consequence of a chain - signal. Under Part 1 no such transaction is required at cutover. -5. SHOULD expose the pending cutover in its status API and log a warning when running a - version whose cutover has passed. - -#### F7. Cutover types and dual-ABI scope - -**Type A — wire-breaking.** The release changes the p2p protocol version, so vN and vN+1 -nodes cannot peer. The client ships a *single* game ABI. A node that has not upgraded by -`activationBlock` stops earning — intended, and the entire content of F1. No dual-mode code -is required, because a non-upgraded node is on the other branch and must not be paid from -this branch's pot. - -**Type B — contract-only.** The wire protocol is unchanged: a `Redistribution` bugfix, a -policy parameter change, a new `PostagePolicy`. Continuity is expected — operators running -a release that carries both bindings MUST keep earning across `activationBlock` — so the -client MUST carry both bindings and switch at `activationBlock`. The legacy binding MAY be -removed in the first release after the cutover. - -**F7.1 — Consensus-path rule.** A cutover that would require a runtime branch in -consensus-critical computation — reserve sampling, commitment hashing, overlay derivation, -depth or eligibility determination, stamp-validity rules — MUST be Type A. A dual-mode -sampler is itself a source of dissent: two nodes disagreeing about which mode they are in -produce divergent reserve commitments, the exact failure F1 exists to prevent. This applies -even when the wire version would not otherwise change: a change to overlay derivation, the -eligibility clock, or the stamp-validity view (such as introducing `refundBatch`, C2.6) -MUST ship as Type A. F7.1 confines Type B's dual-mode surface to contract call sites. - -Under Part 1 the frozen cores never acquire a second ABI, so deposits, withdrawals and -balance reads never branch in either type. Only policy and `Redistribution` bindings do. - -#### F8. Relationship between the parts - -Part 2 alone still requires a stake migration at every fork, and therefore an interval in -which upgraded operators cannot earn. Part 1 alone leaves the fork boundary undefined, so -wire-only forks keep commingling incentives. Together: deposits never move at cutover -(F6.4); `Redistribution` identity still changes per fork (F1); batches carry across, so -there is no batch migration; and the interval "between release and pausing the old -registry" collapses to zero, because there is nothing to pause and nothing to move. +- Clients MUST NOT learn a contract address from the chain and act on it. `manifest` is + checked against the compiled address set; mismatch is a hard failure. +- Activation is observed from `Cutover` state, not a height baked into the binary. A + reschedule MUST be announced at least `CUTOVER_NOTICE` blocks ahead. Inside that + window a schedule MUST NOT be modified (cancellation counts as rescheduling). +- `activationBlock % ROUND_LENGTH_outgoing == 0`. A mid-round cutover orphans commits. + A `ROUND_LENGTH` change is Type A; the incoming game starts on an outgoing boundary. +- The `Cutover` contract holds no funds. A hostile scheduler can delay cutovers, never + redirect money. +- Clients switch the addresses they use at `activationBlock`, not at operator restart, + and MUST NOT send a fund-moving transaction as an automated consequence of a chain + signal. + +**Type A — wire-breaking.** Single game ABI. A node that has not upgraded stops earning, +by design. Required whenever a runtime branch would appear in reserve sampling, +commitment hashing, overlay derivation, eligibility, or stamp validity (including +`refundBatch`). + +**Type B — contract-only.** Wire unchanged. The client carries both bindings and +switches at `activationBlock`. Dual-mode is confined to call sites; cores never acquire +a second ABI. ## Rationale -**Why not "always full redeploy".** *Forking Swarm*'s proposal requires a batch migration -at every breaking wire release, leaves batch migration undesigned, and relies on an -incentive asymmetry that does not hold: operators migrate stake to keep earning, but a user -who fails to migrate a batch loses availability they may not notice until they need the -data. Part 1 removes the requirement rather than solving the coordination problem. - -**Why the registry survives as a cutover signal.** For *security* an on-chain registry adds -nothing: the trust root is the client release process either way. For *coordination* it -adds something real: every client switches at the same block regardless of when its -operator restarted. F2 keeps the coordination; F2.1 removes the security temptation. - -**Why batch creation is policy-gated but exits are not.** Admissibility rules change per -branch and per policy generation; they cannot be frozen. Exits are the security property -and must not depend on any replaceable contract. The asymmetry is deliberate: a hostile -policy can stop new business, never trap existing funds. - -**Why bounds rather than prohibitions.** A policy with no authority over funds cannot -slash and cannot pay winners, and is therefore not an incentive system. The achievable goal -is *bounded, announced, visible* authority with a usable exit — C2.4 through C2.6 made -concrete. - -**Alternatives considered and rejected.** - -- *Immutable policy pointer in the core.* Strictly stronger, but changing policy then means - a new core, which reintroduces migration and defeats the purpose. -- *External timelock contract owning the pointer.* Weaker than C2.5: whoever can replace - the timelock's owner can shorten the window. -- *Expiry ordering in policy.* Rejected: C2.2's accrual settlement requires the core to - know the minimum normalised balance (see C2.2); an ordering index the core cannot trust - would make conservation depend on policy honesty. -- *User-driven batch migration through `fund()`.* Rejected as the primary path: the BZZ - backing existing batches is locked inside `PostageStamp`, which has no extraction path, - so "user-driven" means users pay twice. See Backwards compatibility. -- *Governance vote on policy changes.* Orthogonal and compatible; this SWIP specifies the - contract-level constraints that hold regardless of how the governing address is - constituted. - -## Backwards compatibility - -This is a breaking change to the contract suite and requires a coordinated release. It is -also, by design, intended to be the **last** such change that moves user funds. - -**Final stake migration.** Operators move deposits from `StakeRegistry` to `StakingCore` -via the existing `migrateStake()` path. It MUST be run under the F2/F3 protocol, and — -unlike 2025 — the old registry MUST be paused at `activationBlock`, so the interval between -the client release and the pause is zero. Operators SHOULD pre-register (C3.1) so no -eligibility trough opens. - -**Final batch migration: treasury-matched genesis.** `PostageStamp` cannot release -unexpired deposits (see Motivation), so the batch state must be seeded and separately -backed: - -1. At `activationBlock`, `PostageStamp` is paused (freezing `createBatch`, `topUp`, - `increaseDepth`; expiry and `withdraw` continue to operate). -2. `PostageAccounting` is deployed in a **genesis phase**: the deployer seeds the batch set - — id, owner, depth, bucket depth, immutability, remaining per-chunk balance — exactly as - of `activationBlock`, and MUST transfer in matching BZZ for the full seeded value. The - treasury fronts this float. -3. Genesis is **sealed** in the same ceremony. Until sealed, the core accepts no other - call; after sealing, no seeding path exists and C2.2 holds from the first open block. - Seeding MUST NOT be possible after sealing under any role. -4. The treasury is reimbursed from the old contract as seeded batches' old-side balances - expire into the old pot, using the existing `withdraw(beneficiary)` with the treasury as - beneficiary. This is the final, announced use of the unconstrained withdraw, and it - moves only money the treasury already fronted. The reimbursement horizon equals the - longest remaining batch life; the required float size is an open question. - -`PostageAccounting` contains **no unbacked creation function at any point** — the genesis -seed is deposit-matched by construction, which is the difference from `copyBatch`. - -**Client ABI.** Clients learn a two-contract layout per subsystem: consensus-critical reads -(overlay, effective stake, batch admissibility) from policy; balances, deposits, batch -depth and expiry from the core. A read-only compatibility view of the old -`batches(...)`/`stakes(...)` shapes MAY be deployed for integrators; clients MUST NOT -depend on it for consensus-critical values. +Upgradeable proxies over fund-holding contracts are rejected. A proxy admin can steal +the funds. Checking a registry on every fallback taxes all calls and can revert +withdrawals on a mistaken deprecation. `pinnedExecute` adds a second delegatecall path +and a permanent selector-collision constraint. That machinery solves "the admin swapped +the implementation under me." If deposits live in a contract that cannot be swapped, the +event is no longer a fund-loss event. A registry-like contract is kept only for cutover +*timing*; it is not a security root. The trust root is the client release either way. + +Full-suite redeployment at every fork is rejected for the same reason the split exists. +It requires a batch migration every time, leaves that migration undesigned, and relies +on an incentive that does not hold: operators move stake to keep earning, but a user who +fails to move a batch loses availability they may not notice. After the two one-time +migrations in this SWIP, later upgrades replace policy and `Redistribution` only. + +An immutable policy pointer is stronger and useless: changing policy would mean a new +core, which is another migration. An external timelock is weaker: whoever replaces its +owner shortens the window. Putting expiry ordering in postage policy would make +conservation depend on policy honesty. User-driven `fund()` as the primary batch +migration is rejected because the backing BZZ is locked in `PostageStamp`. + +A policy with no authority over funds cannot slash and cannot pay winners. The +achievable goal is bounded, announced, visible authority with a usable exit. Creation +is policy-gated because admissibility changes per branch; exits are not, because a +hostile policy must not trap existing funds. ## Test cases -Cores are unupgradeable, so the following are mandatory before any core deployment. - -**Invariant tests (must hold after every call, under all orderings).** - -- `pot + sum(remaining claims) <= token.balanceOf(PostageAccounting)` (C2.2), including - across expiry, `resize`, `refundBatch` and price changes. -- `totalDeposited - totalWithdrawn <= token.balanceOf(StakingCore)` (slashing burns in - place, so balance exceeds claims). -- Pot accrual identity: settled pot equals the sum over batches of - `batchSize * min(normalisedBalance, currentTotalOutPayment) - initial credit`, - differentially checked against `PostageStamp.expireLimited` over historical batch data. -- No execution path transfers to an address not derived from core state (C2.1) — enforced - by a static check over core bytecode as well as by tests. -- No core function reaches an external call into the policy address (C2.3). - -**Adversarial-policy tests.** Instantiate each core with a malicious policy attempting, at -minimum: draining the pot in one call; slashing every account to zero; claiming beyond the -window cap; creating a batch whose transfer-in does not match the credited value; resizing -a batch to inflate remaining value; blocking a user's exit; setting a price beyond the -C2.4 bounds. Each MUST revert, and `exit()`/`refundBatch()` MUST succeed throughout. - -**Exit tests.** `exit()` and `refundBatch()` MUST succeed while the policy is malicious, -while the policy address is zero, while a pointer change is pending, and — for -`StakingCore` — while the account is policy-locked. - -**Genesis tests.** Seeding MUST revert without a matching deposit; any call before sealing -MUST revert; seeding after sealing MUST revert from every role; conservation MUST hold at -the first open block. - -**Timelock and window tests.** A pointer change MUST NOT execute before `POLICY_TIMELOCK` -nor outside `[activationBlock, activationBlock + EXECUTION_WINDOW)`; cancellation works -only before execution; the pending change is readable throughout. - -**Cutover tests.** A boundary-aligned cutover MUST NOT orphan a committed node (F3); an -off-boundary schedule MUST revert; a `manifest` mismatch MUST hard-fail the client. - -**Accumulator continuity tests.** A policy replacement MUST NOT change -`currentTotalOutPayment()`, `normalisedBalanceOf()` or `remainingBalance()` for any batch, -tested across a pending price update and mid-expiry. - -**Expiry self-verification tests.** `expire()` MUST credit the pot only for ids the core -independently computes as expired, MUST be safe under arbitrary, duplicated, non-existent -or unexpired ids, and live-chunk accrual MUST NOT settle while an expired batch remains -counted. - -**Eligibility clock tests.** A pre-registered operator MUST be eligible at -`activationBlock` without settling delay (C3.1); a non-pre-registered one MUST NOT be. - -**Fuzz and differential.** Fuzz conservation across randomised sequences of deposit, -fund, top-up, resize, price update, expire, claim, slash, refund, withdraw and exit. -Differentially test core accounting against the current `PostageStamp` over historical -data to confirm identical remaining-balance and expiry results. +Mandatory before any core deployment. + +- Conservation after every call, including `resize`, `refundBatch`, expiry and price + changes. Differentially check postage pot accrual against today's `expireLimited`. +- No transfer to an address not derived from core state (static check on bytecode). +- No core call into the policy address. +- Malicious policy: flash-drain, unbounded slash, over-claim, unbacked fund, value-inflating + resize, blocked exit, over-max price. All revert; `exit` / `refundBatch` still succeed + (including policy = 0, pending pointer change, and a locked staking account). +- Genesis: seeding without matching BZZ reverts; any call before seal reverts; seeding + after seal reverts from every role; conservation holds at the first open block. +- Pointer changes cannot execute before `POLICY_TIMELOCK` or outside the execution + window; cancellation works only before execution. +- Boundary-aligned cutover does not orphan a commit; off-boundary schedule reverts; + `manifest` mismatch hard-fails the client. +- Policy replacement does not change `currentTotalOutPayment` or remaining balances. +- Pre-registered operators are eligible at `activationBlock`; others are not. +- Fuzz randomised sequences of deposit, fund, top-up, resize, price, expire, claim, + slash, refund, withdraw and exit. ## Implementation -Staged so that each stage is independently valuable and independently revertible. +Each stage is independently valuable and independently revertible. | Stage | Content | Depends on | |---|---|---| -| 1 | Surgical `Redistribution` redeployment with security fixes; round-aligned cutover; singleton redistributor *by operational discipline* (one role holder; the type-level guarantee lands in stage 5) | — | -| 2 | F1 adopted as standing practice: new `Redistribution` on every breaking wire release | — | -| 3 | `Cutover` contract and client support (F2, F3, F6, F7). `storage-incentives#310` reduced to a plain release registry; guarded proxy and `pinnedExecute` dropped | 2 | +| 1 | New `Redistribution`; round-aligned cutover; one redistributor by operational discipline | — | +| 2 | New `Redistribution` on every breaking wire release, as standing practice | — | +| 3 | `Cutover` contract and client support. Drop guarded proxies / `pinnedExecute` | 2 | | 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | -| 5 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis; `copyBatch` retired | 4 | -| 6 | Governing multisig scope reduced to policy pointers | 5 | +| 5 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 4 | +| 6 | Multisig scope reduced to policy and redistributor pointers | 5 | + +`PriceOracle` has no dedicated stage: redeploy it with the postage or redistribution +release that needs the new adjustment rules. -Stage 1 addresses measured harm and is the immediate next upgrade. Stage 2 is available -today at no cost. Stages 4 and 5 are where the custody property lands. After stage 5, -surgical redeployment and the absence of admin power over deposits coexist — the two things -currently treated as mutually exclusive. +After stage 5, surgical redeployment and the absence of admin power over deposits +coexist. ## Open questions 1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` - (MUST be ≥ the maximum freeze horizon, `penaltyMultiplier * ROUND_LENGTH * 2^depth` at - plausible depths), `EXECUTION_WINDOW`, `CUTOVER_NOTICE`, `MAX_SLASH_PER_WINDOW`, - `SLASH_WINDOW`, `MAX_POT_FRACTION_PER_WINDOW`, `CLAIM_WINDOW`, `MAX_PRICE`, - `MAX_PRICE_CHANGE_PER_UPDATE` (MUST be compatible with `PriceOracle`'s adjustment - steps). Immutable once deployed; each needs a written derivation, not a suggestion. -2. **Refund economics.** The `refundBatch` forfeit fraction, and the wind-down decay - schedule (F5). A forfeit fraction is preferred over a minimum batch age: age penalises - exactly the long-lived honest batches C2.6 exists for. -3. **Treasury float.** The genesis migration requires the treasury to front the full - remaining batch value and be reimbursed over the longest batch life. What float is - acceptable, and should a deadline cap the reimbursement tail? -4. **Multi-client discipline.** F2, F3 and F6 assume every client implements cutover - identically. What is the conformance mechanism if a second client exists? -5. **Frozen outpayment model.** The accumulator in the core freezes linear per-block - accrual (C2.7). The considered alternative is to stop denominating in a global - accumulator: store each batch's remaining BZZ explicitly and let policy consume it into - the pot under a C2.4 rate cap. That keeps pricing-model changes policy-side and lets - users refund under a hostile consumption schedule, at the cost of a larger core and a - harder expiry index. Is linear accrual the model to commit to indefinitely, or is - remaining-BZZ-plus-capped-consumption the better thing to freeze? - -## References - -- [`ethersphere/storage-incentives#310`][pr310] — Versioned Registry Router + Upgradeable - Proxies for All Core Contracts, and the review discussion that motivated this SWIP. -- *Forking Swarm: A migration guide* — Andrew Macpherson, Shtuka Research (presentation, - 2026). -- Deployed contracts referenced throughout: `src/PostageStamp.sol`, `src/Staking.sol`, - `src/Redistribution.sol` in `ethersphere/storage-incentives`. - -[pr310]: https://github.com/ethersphere/storage-incentives/pull/310 - -## Acknowledgements - -Part 2 is substantially derived from Andrew Macpherson's *Forking Swarm* presentation -(Shtuka Research) and his review of [`storage-incentives#310`][pr310]: the fork and -fork-migration framing (F0); the argument that contract identity, not wire version, -partitions the incentive game (F1); the v2.8.0 dissent measurements and the v0.9.3/v0.9.4 -case study; the assessment that upgradeable staking is a strict increase in attack surface; -and the observation that the interval between a client release and the pausing of the old -registry is dead time for upgraded operators. - -Review of the first draft materially changed Part 1: the outpayment accumulator cannot live -in the replaceable half (C2.7), and the dual-ABI maintenance argument answered by F7.1 -(Mark Bliss). A subsequent review pass established that batch sizes and the expiry -ordering must also be core-side for C2.2 to be enforceable, that `PostageStamp` has no -deposit-extraction path — forcing the treasury-matched genesis design — and the F3 -execution-window form of cutover. - -This SWIP departs from *Forking Swarm* on one conclusion, set out in -[Motivation](#motivation). + (≥ maximum freeze horizon), `EXECUTION_WINDOW`, `CUTOVER_NOTICE`, slash and pot + windows, `MAX_PRICE` and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's + steps). Immutable once deployed. +2. **Refund economics.** `refundBatch` forfeit fraction, and the wind-down decay + schedule. A forfeit is preferred over a minimum batch age. +3. **Treasury float.** Size of the genesis front, and whether a deadline caps the + reimbursement tail. +4. **Multi-client discipline.** What conformance looks like if a second client exists. +5. **Frozen outpayment model.** Keep linear per-chunk accrual in the core, or freeze + remaining-BZZ with rate-capped policy consumption instead, so a later pricing model + is still a policy change. ## Copyright From cbcb6103a64d9faed491a36f344c6395f6f94a37 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 16:34:18 +0200 Subject: [PATCH 10/24] swip-67: spell out when Redistribution is redeployed and why --- SWIPs/swip-67.md | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 5e9b5ee1..73736ba6 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -42,9 +42,9 @@ it, is never upgraded, and enforces its own accounting invariants. The policy ho rules, is freely replaceable, and can never name a payment destination. `Redistribution` and `PriceOracle` hold no user deposits, so they are not split. They stay -replaceable contracts and are redeployed as-is: a new `Redistribution` on every breaking -wire release, so forked networks do not share one game; `PriceOracle` whenever its -adjustment rules change. +replaceable and are redeployed as-is. A new `Redistribution` whenever the on-chain game +must not be shared — a breaking Bee wire release (even if the Solidity is unchanged) or a +change to Redistribution itself. A new `PriceOracle` when its adjustment rules change. It then specifies the **cutover protocol** — how clients switch to a new policy and a new `Redistribution` at a round boundary, so a protocol upgrade stops being a fund movement. @@ -53,9 +53,11 @@ It then specifies the **cutover protocol** — how clients switch to a new polic The suite is treated contract by contract. -- **`Redistribution`** is not split. Every breaking wire release deploys a new one, even - when bytecode is unchanged. Cutover lands on a round boundary; at most one redistributor - is authorised at any block. +- **`Redistribution`** is not split. A new contract is deployed whenever the *game* + partitions — a breaking Bee wire release (peers with different protocol versions cannot + connect, so two networks must not share one on-chain game) or a Redistribution code + change. Same bytecode still gets a new address on a wire break. Cutover lands on a round + boundary; at most one redistributor is authorised at any block. - **`StakeRegistry`** splits into `StakingCore` (deposits, exits) and `StakingPolicy` (overlay, height, effective stake, slash/freeze rules). Operators migrate stake once, then deposits stay put across later forks. @@ -134,15 +136,25 @@ preserving across a release. Overlay, stake and freeze data live in staking; pos balances live in postage. Redistribution only *reads* those and *calls* `claimPot` / `slash` / `lock`. -**How it is updated.** Every breaking wire release deploys a **new** `Redistribution`, -even if the bytecode is unchanged. Contract identity, not the wire version, is what -partitions the incentive game. Without a new contract, both branches of a wire fork play -the same game with divergent stamp-set views: stragglers claim payments meant for the -new branch, and honest nodes can be frozen for disagreeing with a non-upgraded leader. - -A contract-only bugfix (Type B) also deploys a new `Redistribution` and switches the -postage core's redistributor pointer; the wire protocol is unchanged and operators who -already run a dual-binding release keep earning. +**How it is updated.** Redeploy when the incentive game must not be shared. Two triggers: + +1. **Breaking wire release (Type A).** Bee ships a new *peer protocol version*. Nodes on + the old version and the new version cannot connect, so the p2p network splits in two. + Those two networks still see different chunks, stamps and reserve commitments, but if + they keep calling the **same** `Redistribution` address they play one on-chain commit / + reveal / claim game. The contract cannot tell the branches apart. Divergent reveals + look like lying: stragglers can win the pot, upgraded nodes get frozen for "disagreeing." + A new `Redistribution` address is the partition. The Solidity can be identical — what + changed is the off-chain protocol, not the contract. Deploy a new copy, point clients + and the postage redistributor pointer at it. +2. **`Redistribution` itself changed (Type A or B).** A bugfix, a new claim check, a + different round length. There is no upgrade path, so that is also a new deployment. + If the wire is unchanged this is Type B: operators already running a release with both + addresses keep earning across cutover. + +Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjustment, or +a staking-policy change that does not change how commits are built or verified. Those +replace the other contracts; the game address stays if the game is the same. **Cutover.** Incoming `Redistribution` accepts commits from `activationBlock` onward. The postage core authorises at most one redistributor address at a time. The pointer From c43bac0f96d911043ca6c1d78c06a25623353a18 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 16:40:05 +0200 Subject: [PATCH 11/24] swip-67: say breaking Bee release, drop fork/wire jargon --- SWIPs/swip-67.md | 88 +++++++++++++++++++++++++----------------------- 1 file changed, 46 insertions(+), 42 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 73736ba6..2cdddd50 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -43,7 +43,7 @@ rules, is freely replaceable, and can never name a payment destination. `Redistribution` and `PriceOracle` hold no user deposits, so they are not split. They stay replaceable and are redeployed as-is. A new `Redistribution` whenever the on-chain game -must not be shared — a breaking Bee wire release (even if the Solidity is unchanged) or a +must not be shared — a breaking Bee release (even if the Solidity is unchanged) or a change to Redistribution itself. A new `PriceOracle` when its adjustment rules change. It then specifies the **cutover protocol** — how clients switch to a new policy and a new @@ -54,16 +54,16 @@ It then specifies the **cutover protocol** — how clients switch to a new polic The suite is treated contract by contract. - **`Redistribution`** is not split. A new contract is deployed whenever the *game* - partitions — a breaking Bee wire release (peers with different protocol versions cannot - connect, so two networks must not share one on-chain game) or a Redistribution code - change. Same bytecode still gets a new address on a wire break. Cutover lands on a round - boundary; at most one redistributor is authorised at any block. + must not be shared — a breaking Bee release (old and new Bee nodes cannot connect, so + they must not share one on-chain game) or a Redistribution code change. Same bytecode + still gets a new address on a breaking Bee release. Cutover lands on a round boundary; + at most one redistributor is authorised at any block. - **`StakeRegistry`** splits into `StakingCore` (deposits, exits) and `StakingPolicy` (overlay, height, effective stake, slash/freeze rules). Operators migrate stake once, - then deposits stay put across later forks. + then deposits stay put across later cutovers. - **`PostageStamp`** splits into `PostageAccounting` (balances, accumulator, pot, expiry ordering) and `PostagePolicy` (admissibility, depth rules, price submission). Batches - are seeded once, treasury-matched; after that they carry across forks. + are seeded once, treasury-matched; after that they carry across later cutovers. - **`PriceOracle`** is not split. It is redeployed when adjustment rules change, and submits prices through `PostagePolicy` into the core's bounded `setPrice`. @@ -99,9 +99,10 @@ attack surface, from burn to steal. Its escape hatch, `migrateStake()`, is `when and `pause()` requires `DEFAULT_ADMIN_ROLE` — so against the admin it is not an escape hatch. -Custody separation removes the on-chain cost of a migration. It does not remove the fork: -bucket counters and chunk availability still partition on a wire change. Batches carry -across; the stamp set still forks. +Custody separation removes the on-chain cost of a migration. It does not make old and new +Bee nodes share chunks: after a breaking Bee release they cannot peer, so bucket counters +and local chunk state stay on each network. On-chain batches carry across; that local +state does not. ## Specification @@ -138,19 +139,19 @@ balances live in postage. Redistribution only *reads* those and *calls* `claimPo **How it is updated.** Redeploy when the incentive game must not be shared. Two triggers: -1. **Breaking wire release (Type A).** Bee ships a new *peer protocol version*. Nodes on - the old version and the new version cannot connect, so the p2p network splits in two. - Those two networks still see different chunks, stamps and reserve commitments, but if - they keep calling the **same** `Redistribution` address they play one on-chain commit / - reveal / claim game. The contract cannot tell the branches apart. Divergent reveals - look like lying: stragglers can win the pot, upgraded nodes get frozen for "disagreeing." - A new `Redistribution` address is the partition. The Solidity can be identical — what - changed is the off-chain protocol, not the contract. Deploy a new copy, point clients - and the postage redistributor pointer at it. +1. **Breaking Bee release (Type A).** A Bee version whose nodes cannot connect to the + previous version. The p2p network splits in two. Those two networks see different + chunks and reserve commitments, but if they keep calling the **same** `Redistribution` + address they play one on-chain commit / reveal / claim game. The contract cannot tell + the two networks apart. Divergent reveals look like lying: old-version nodes can win + the pot, upgraded nodes get frozen for "disagreeing." A new `Redistribution` address + is what separates the games. The Solidity can be identical — what changed is Bee, not + the contract. Deploy a new copy, point clients and the postage redistributor pointer + at it. 2. **`Redistribution` itself changed (Type A or B).** A bugfix, a new claim check, a different round length. There is no upgrade path, so that is also a new deployment. - If the wire is unchanged this is Type B: operators already running a release with both - addresses keep earning across cutover. + If Bee's p2p protocol is unchanged this is Type B: operators already running a release + with both addresses keep earning across cutover. Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjustment, or a staking-policy change that does not change how commits are built or verified. Those @@ -163,7 +164,7 @@ only inside `[activationBlock, activationBlock + EXECUTION_WINDOW)`. Until execu the outgoing contract remains authorised, so a late execution shortens the first new round rather than orphaning a committed node. `claimPot` reverts for any other caller. -If the old branch still needs to pay for data availability, the outgoing +If the previous Bee network still needs to pay for data availability, the outgoing `Redistribution` MAY keep paying at a reduced, decaying rate. That pot MUST be moved into the outgoing contract **before** cutover. After cutover it is never the authorised pointer again. @@ -182,8 +183,8 @@ Split. `StakeRegistry` becomes `StakingCore` + `StakingPolicy`. | Per-account deposit, `firstDepositBlock`, withdrawal and exit accounting | Overlay derivation, height, committed stake, effective stake, freeze and slash rules | `StakingCore` MUST NOT store overlays, heights, committed stake or effective stake, and -MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so it is redeployed with a fork; -deposits are not. +MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so it is redeployed with a +breaking Bee release; deposits are not. ```solidity interface IStakingCore { @@ -219,12 +220,12 @@ committed stake exceeds the account's deposit. A slash reduces the account, and every overlay it backs. `StakingPolicy` SHOULD take an immutable `predecessor` and lazily inherit overlay and -height on first use, so a later fork needs no operator transaction. +height on first use, so a later cutover needs no operator transaction. **Migration (once).** Operators move deposits with today's `migrateStake()` onto `StakingCore`. The old registry is paused at `activationBlock`, not later. Operators SHOULD pre-register so they are eligible immediately. After this, stake does not move -again: later forks only replace `StakingPolicy`. +again: later cutovers only replace `StakingPolicy`. **Cutover after the split.** Policy-pointer change on `StakingCore` under `POLICY_TIMELOCK`. Deposits, withdrawals and exits never change ABI. @@ -304,7 +305,7 @@ deposits, so the new core is seeded and separately backed: expire into the old pot, using `withdraw` with the treasury as beneficiary — the final announced use of that primitive. The tail equals the longest remaining batch life. -After this, batches do not migrate again. Later forks only replace `PostagePolicy` and +After this, batches do not migrate again. Later cutovers only replace `PostagePolicy` and `Redistribution`. **Cutover after the split.** Redistributor pointer as in [Redistribution](#redistribution). @@ -323,8 +324,8 @@ All of it is replaceable. The postage core stores `lastPrice` and the accumulato oracle does not. **How it is updated.** Redeployed when adjustment rules change (the rate table, the -redundancy target, the pause behaviour). A Type B cutover if the wire is unchanged; Type -A if a consensus-critical consumer would need a runtime branch. +redundancy target, the pause behaviour). A Type B cutover if Bee's p2p protocol is +unchanged; Type A if a consensus-critical consumer would need a runtime branch. Price submission is `PriceOracle` → `PostagePolicy` → `PostageAccounting.setPrice`. The core enforces `price <= MAX_PRICE` and a maximum step from `lastPrice`. Those bounds @@ -336,20 +337,22 @@ not in the oracle. ### Cutover protocol -Shared by all four. A **fork** is a second network cloned from a subset of the first -(canonically the stamp set). A **breaking wire release** is a client whose peer protocol -version differs, so mismatched peers disconnect. +Shared by all four. A **breaking Bee release** is a Bee version whose nodes cannot +connect to the previous version, so the p2p network splits in two. A **cutover** is the +coordinated switch of contract addresses at a round boundary. Type A cutovers follow a +breaking Bee release. Type B cutovers change contracts only; old and new Bee nodes can +still peer. A `Cutover` contract publishes **when**. Addresses live in the client binary. ```solidity interface ICutover { struct Schedule { - uint32 wireVersion; + uint32 protocolVersion; // Bee p2p protocol version this cutover activates uint64 activationBlock; // round boundary of the outgoing game bytes32 manifest; // hash of the release's address set } - function schedule(uint32 wireVersion) external view returns (Schedule memory); + function schedule(uint32 protocolVersion) external view returns (Schedule memory); function current() external view returns (Schedule memory); } ``` @@ -367,14 +370,14 @@ interface ICutover { and MUST NOT send a fund-moving transaction as an automated consequence of a chain signal. -**Type A — wire-breaking.** Single game ABI. A node that has not upgraded stops earning, -by design. Required whenever a runtime branch would appear in reserve sampling, +**Type A — breaking Bee release.** Single game ABI. A node that has not upgraded stops +earning, by design. Required whenever a runtime branch would appear in reserve sampling, commitment hashing, overlay derivation, eligibility, or stamp validity (including `refundBatch`). -**Type B — contract-only.** Wire unchanged. The client carries both bindings and -switches at `activationBlock`. Dual-mode is confined to call sites; cores never acquire -a second ABI. +**Type B — contract-only.** Bee's p2p protocol is unchanged. The client carries both +bindings and switches at `activationBlock`. Dual-mode is confined to call sites; cores +never acquire a second ABI. ## Rationale @@ -386,7 +389,8 @@ the implementation under me." If deposits live in a contract that cannot be swap event is no longer a fund-loss event. A registry-like contract is kept only for cutover *timing*; it is not a security root. The trust root is the client release either way. -Full-suite redeployment at every fork is rejected for the same reason the split exists. +Full-suite redeployment at every breaking Bee release is rejected for the same reason the +split exists. It requires a batch migration every time, leaves that migration undesigned, and relies on an incentive that does not hold: operators move stake to keep earning, but a user who fails to move a batch loses availability they may not notice. After the two one-time @@ -400,7 +404,7 @@ migration is rejected because the backing BZZ is locked in `PostageStamp`. A policy with no authority over funds cannot slash and cannot pay winners. The achievable goal is bounded, announced, visible authority with a usable exit. Creation -is policy-gated because admissibility changes per branch; exits are not, because a +is policy-gated because admissibility changes per Bee release; exits are not, because a hostile policy must not trap existing funds. ## Test cases @@ -432,7 +436,7 @@ Each stage is independently valuable and independently revertible. | Stage | Content | Depends on | |---|---|---| | 1 | New `Redistribution`; round-aligned cutover; one redistributor by operational discipline | — | -| 2 | New `Redistribution` on every breaking wire release, as standing practice | — | +| 2 | New `Redistribution` on every breaking Bee release, as standing practice | — | | 3 | `Cutover` contract and client support. Drop guarded proxies / `pinnedExecute` | 2 | | 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | | 5 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 4 | From 639fa0064772e6659fa4dfebf7323208f1f02020 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 16:58:49 +0200 Subject: [PATCH 12/24] swip-67: drop Redistribution pot-hop aside; allow refund of immutable batches --- SWIPs/swip-67.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 2cdddd50..61c3aa83 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -128,9 +128,7 @@ advance with an exit window. ### Redistribution -Not split. It holds no user deposits. It MAY transiently hold pot funds between -`claimPot` and winner payout; that hop SHOULD complete in one transaction. Stranded BZZ -there is governance-recoverable — protocol funds, not deposits. +Not split. It holds no user deposits. **State.** Commits, reveals, round counters and the last winner. None of it is worth preserving across a release. Overlay, stake and freeze data live in staking; postage @@ -288,8 +286,11 @@ whole pot each round. Protection against a hostile redistributor is the timelock `refundBatch`. `refundBatch` pays the recorded owner and SHOULD forfeit a fraction to the pot. -Immutable batches are not refundable. Nodes treat a refund as batch invalidation, same -as expiry. Introducing `refundBatch` is Type A: stamp validity is consensus-adjacent. +Immutable batches are refundable too: immutability means the batch cannot be topped up +or resized while it is alive, not that the owner is locked in. A refund is early expiry; +nodes already handle that. Blocking it would trap the owners who most need the exit. +Nodes treat a refund as batch invalidation, same as expiry. Introducing `refundBatch` is +Type A: stamp validity is consensus-adjacent. **Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired deposits, so the new core is seeded and separately backed: From a5ea04508b24676c1cb158903c0771b93ef0fe09 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:00:15 +0200 Subject: [PATCH 13/24] swip-67: drop batch immutableFlag, matching storage-incentives#319 --- SWIPs/swip-67.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 61c3aa83..fb825794 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -251,7 +251,7 @@ normalised balance. A different model is not a policy change; it would need a ne interface IPostageAccounting { function fund( address originator, bytes32 nonce, address owner, - uint8 depth, bool immutableFlag, uint256 amountPerChunk + uint8 depth, uint256 amountPerChunk ) external returns (bytes32 batchId); function resize(bytes32 batchId, uint8 newDepth) external; function setPrice(uint256 price) external; @@ -285,12 +285,9 @@ is capped per window. The cap limits *acceleration*; the honest game already pay whole pot each round. Protection against a hostile redistributor is the timelock plus `refundBatch`. -`refundBatch` pays the recorded owner and SHOULD forfeit a fraction to the pot. -Immutable batches are refundable too: immutability means the batch cannot be topped up -or resized while it is alive, not that the owner is locked in. A refund is early expiry; -nodes already handle that. Blocking it would trap the owners who most need the exit. -Nodes treat a refund as batch invalidation, same as expiry. Introducing `refundBatch` is -Type A: stamp validity is consensus-adjacent. +`refundBatch` pays the recorded owner and SHOULD forfeit a fraction to the pot. Nodes +treat a refund as batch invalidation, same as expiry. Introducing `refundBatch` is Type +A: stamp validity is consensus-adjacent. **Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired deposits, so the new core is seeded and separately backed: From 4713f01785d1c279c28ca073845b16cc43f042a7 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:03:29 +0200 Subject: [PATCH 14/24] swip-67: drop the Cutover contract; Bee keeps compiled-in addresses --- SWIPs/swip-67.md | 127 ++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 67 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index fb825794..1a3a95b0 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -10,7 +10,7 @@ created: 2026-09-07 --- +funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boundary. --> ## Contents @@ -21,7 +21,7 @@ funds, and specifies the cutover protocol that replacement runs under. --> - [Staking](#staking) - [PostageStamp](#postagestamp) - [PriceOracle](#priceoracle) - - [Cutover protocol](#cutover-protocol) + - [Cutover](#cutover) - [Rationale](#rationale) - [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) @@ -46,8 +46,9 @@ replaceable and are redeployed as-is. A new `Redistribution` whenever the on-cha must not be shared — a breaking Bee release (even if the Solidity is unchanged) or a change to Redistribution itself. A new `PriceOracle` when its adjustment rules change. -It then specifies the **cutover protocol** — how clients switch to a new policy and a new -`Redistribution` at a round boundary, so a protocol upgrade stops being a fund movement. +It then specifies how a new `Redistribution` and a new policy are pointed in at a round +boundary, so a protocol upgrade stops being a fund movement. Bee keeps shipping contract +addresses in the binary, as it does today. There is no on-chain `Cutover` contract. ## Abstract @@ -68,8 +69,8 @@ The suite is treated contract by contract. submits prices through `PostagePolicy` into the core's bounded `setPrice`. Cores hold all user BZZ. No core function transfers to a caller-supplied address. Pointers -change only after a core-enforced timelock. Exits cannot be paused. A `Cutover` contract -publishes *timing only*; contract addresses are compiled into the client. +change only after a core-enforced timelock. Exits cannot be paused. Contract addresses +stay compiled into Bee, as they are today. ## Motivation @@ -155,10 +156,12 @@ Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjus a staking-policy change that does not change how commits are built or verified. Those replace the other contracts; the game address stays if the game is the same. -**Cutover.** Incoming `Redistribution` accepts commits from `activationBlock` onward. -The postage core authorises at most one redistributor address at a time. The pointer -moves through `proposeRedistributor` / `executeRedistributor` under the timelock and -only inside `[activationBlock, activationBlock + EXECUTION_WINDOW)`. Until execution, +**Cutover.** Incoming `Redistribution` accepts commits from the round-boundary block +where the postage core's redistributor pointer is executed onward. The postage core +authorises at most one redistributor address at a time. The pointer moves through +`proposeRedistributor` / `executeRedistributor` under the timelock and only inside +`[activationBlock, activationBlock + EXECUTION_WINDOW)`, where `activationBlock` is a +round boundary of the outgoing game, announced with the Bee release. Until execution, the outgoing contract remains authorised, so a late execution shortens the first new round rather than orphaning a committed node. `claimPot` reverts for any other caller. @@ -167,8 +170,8 @@ If the previous Bee network still needs to pay for data availability, the outgoi into the outgoing contract **before** cutover. After cutover it is never the authorised pointer again. -**Migration.** None. There is no user state to move. Clients switch the address they -call at `activationBlock`. Until `PostageAccounting` exists, singleton authority is +**Migration.** None. There is no user state to move. Operators run the Bee release that +contains the new address. Until `PostageAccounting` exists, singleton authority is operational (one `REDISTRIBUTOR_ROLE` holder); the type-level guarantee lands with the postage split. @@ -322,60 +325,44 @@ All of it is replaceable. The postage core stores `lastPrice` and the accumulato oracle does not. **How it is updated.** Redeployed when adjustment rules change (the rate table, the -redundancy target, the pause behaviour). A Type B cutover if Bee's p2p protocol is -unchanged; Type A if a consensus-critical consumer would need a runtime branch. +redundancy target, the pause behaviour). Ship the new address in Bee; flip nothing on +the postage core except through the existing `setPrice` path. Type A only if a +consensus-critical consumer would need a runtime branch. Price submission is `PriceOracle` → `PostagePolicy` → `PostageAccounting.setPrice`. The core enforces `price <= MAX_PRICE` and a maximum step from `lastPrice`. Those bounds MUST be compatible with the oracle's own steps, or honest adjustments revert. -**Migration.** None. Clients switch the compiled oracle address at `activationBlock`. -In-flight postage balances are unaffected: they are denominated in the core accumulator, -not in the oracle. +**Migration.** None. Operators run the Bee release that contains the new oracle +address. In-flight postage balances are unaffected: they are denominated in the core +accumulator, not in the oracle. -### Cutover protocol +### Cutover -Shared by all four. A **breaking Bee release** is a Bee version whose nodes cannot -connect to the previous version, so the p2p network splits in two. A **cutover** is the -coordinated switch of contract addresses at a round boundary. Type A cutovers follow a -breaking Bee release. Type B cutovers change contracts only; old and new Bee nodes can -still peer. +No extra contract. Bee ships the current addresses and ABIs in the binary, as it does +today. Operators switch by running that Bee. Governance flips the postage redistributor +pointer (and any policy pointer) at a round boundary of the outgoing game. -A `Cutover` contract publishes **when**. Addresses live in the client binary. - -```solidity -interface ICutover { - struct Schedule { - uint32 protocolVersion; // Bee p2p protocol version this cutover activates - uint64 activationBlock; // round boundary of the outgoing game - bytes32 manifest; // hash of the release's address set - } - function schedule(uint32 protocolVersion) external view returns (Schedule memory); - function current() external view returns (Schedule memory); -} -``` - -- Clients MUST NOT learn a contract address from the chain and act on it. `manifest` is - checked against the compiled address set; mismatch is a hard failure. -- Activation is observed from `Cutover` state, not a height baked into the binary. A - reschedule MUST be announced at least `CUTOVER_NOTICE` blocks ahead. Inside that - window a schedule MUST NOT be modified (cancellation counts as rescheduling). -- `activationBlock % ROUND_LENGTH_outgoing == 0`. A mid-round cutover orphans commits. - A `ROUND_LENGTH` change is Type A; the incoming game starts on an outgoing boundary. -- The `Cutover` contract holds no funds. A hostile scheduler can delay cutovers, never - redirect money. -- Clients switch the addresses they use at `activationBlock`, not at operator restart, - and MUST NOT send a fund-moving transaction as an automated consequence of a chain - signal. - -**Type A — breaking Bee release.** Single game ABI. A node that has not upgraded stops +A **breaking Bee release (Type A)** is a Bee version whose nodes cannot connect to the +previous version. Ship a new `Redistribution` in that binary. Non-upgraded nodes stop earning, by design. Required whenever a runtime branch would appear in reserve sampling, commitment hashing, overlay derivation, eligibility, or stamp validity (including `refundBatch`). -**Type B — contract-only.** Bee's p2p protocol is unchanged. The client carries both -bindings and switches at `activationBlock`. Dual-mode is confined to call sites; cores -never acquire a second ABI. +A **contract-only release (Type B)** does not change Bee's p2p protocol. Still ship the +new addresses in Bee; still flip the pointer at a round boundary. There is no dual-ABI +mode: a node that has not upgraded is calling the retired address and stops earning once +the pointer has moved. That is the same operator duty as today, without overlapping +redistributors. + +`activationBlock % ROUND_LENGTH_outgoing == 0`. A mid-round flip orphans commits. A +`ROUND_LENGTH` change is Type A; the incoming game starts on an outgoing boundary. +Clients MUST NOT send a fund-moving transaction as an automated consequence of an +upgrade or a chain event. + +An on-chain `Cutover` registry, guarded proxies, and `pinnedExecute` are not used. They +would only duplicate the Bee release: addresses already live in the binary, and a chain +signal cannot be allowed to redirect funds. ## Rationale @@ -384,8 +371,15 @@ the funds. Checking a registry on every fallback taxes all calls and can revert withdrawals on a mistaken deprecation. `pinnedExecute` adds a second delegatecall path and a permanent selector-collision constraint. That machinery solves "the admin swapped the implementation under me." If deposits live in a contract that cannot be swapped, the -event is no longer a fund-loss event. A registry-like contract is kept only for cutover -*timing*; it is not a security root. The trust root is the client release either way. +event is no longer a fund-loss event. + +An on-chain `Cutover` contract is rejected for the same reason. Its only job would be +telling Bee *when* to switch addresses that Bee already compiled in. Type A does not +need that: old and new Bee cannot peer, and the new binary already has the new +`Redistribution`. Type B would need it only to run two ABIs at once without restarting. +That is not worth a new contract, a dual-mode client, and a rescheduling protocol. +Operators upgrade Bee; governance flips the pointer at a round boundary; anyone still +on the old binary stops earning. The trust root is the Bee release either way. Full-suite redeployment at every breaking Bee release is rejected for the same reason the split exists. @@ -420,8 +414,8 @@ Mandatory before any core deployment. after seal reverts from every role; conservation holds at the first open block. - Pointer changes cannot execute before `POLICY_TIMELOCK` or outside the execution window; cancellation works only before execution. -- Boundary-aligned cutover does not orphan a commit; off-boundary schedule reverts; - `manifest` mismatch hard-fails the client. +- A pointer flip off a round boundary reverts; a boundary-aligned flip does not orphan + a commit. - Policy replacement does not change `currentTotalOutPayment` or remaining balances. - Pre-registered operators are eligible at `activationBlock`; others are not. - Fuzz randomised sequences of deposit, fund, top-up, resize, price, expire, claim, @@ -433,25 +427,24 @@ Each stage is independently valuable and independently revertible. | Stage | Content | Depends on | |---|---|---| -| 1 | New `Redistribution`; round-aligned cutover; one redistributor by operational discipline | — | +| 1 | New `Redistribution`; round-aligned pointer flip; one redistributor by operational discipline | — | | 2 | New `Redistribution` on every breaking Bee release, as standing practice | — | -| 3 | `Cutover` contract and client support. Drop guarded proxies / `pinnedExecute` | 2 | -| 4 | `StakingCore` + `StakingPolicy`. Final stake migration | 3 | -| 5 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 4 | -| 6 | Multisig scope reduced to policy and redistributor pointers | 5 | +| 3 | `StakingCore` + `StakingPolicy`. Final stake migration | 2 | +| 4 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 3 | +| 5 | Multisig scope reduced to policy and redistributor pointers | 4 | `PriceOracle` has no dedicated stage: redeploy it with the postage or redistribution release that needs the new adjustment rules. -After stage 5, surgical redeployment and the absence of admin power over deposits +After stage 4, surgical redeployment and the absence of admin power over deposits coexist. ## Open questions 1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` - (≥ maximum freeze horizon), `EXECUTION_WINDOW`, `CUTOVER_NOTICE`, slash and pot - windows, `MAX_PRICE` and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's - steps). Immutable once deployed. + (≥ maximum freeze horizon), `EXECUTION_WINDOW`, slash and pot windows, `MAX_PRICE` + and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's steps). Immutable once + deployed. 2. **Refund economics.** `refundBatch` forfeit fraction, and the wind-down decay schedule. A forfeit is preferred over a minimum batch age. 3. **Treasury float.** Size of the genesis front, and whether a deadline caps the From 79ddd45d7c7be30f0b9ab8611d560985c003694e Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:25:03 +0200 Subject: [PATCH 15/24] swip-67: title Custody separation; author Cardinal; drop leftover Cutover wording --- SWIPs/swip-67.md | 73 +++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 44 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 1a3a95b0..46bc5444 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -1,7 +1,7 @@ --- SWIP: 67 -title: Custody separation and cutover -author: Cardinal (@0xCardiE), Andrew Macpherson (@awmacpherson) +title: Custody separation +author: Cardinal (@0xCardiE) discussions-to: https://github.com/ethersphere/SWIPs/pull/108 status: Draft type: Standards Track @@ -21,7 +21,7 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [Staking](#staking) - [PostageStamp](#postagestamp) - [PriceOracle](#priceoracle) - - [Cutover](#cutover) + - [Releases](#releases) - [Rationale](#rationale) - [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) @@ -48,7 +48,7 @@ change to Redistribution itself. A new `PriceOracle` when its adjustment rules c It then specifies how a new `Redistribution` and a new policy are pointed in at a round boundary, so a protocol upgrade stops being a fund movement. Bee keeps shipping contract -addresses in the binary, as it does today. There is no on-chain `Cutover` contract. +addresses in the binary, as it does today. ## Abstract @@ -57,14 +57,14 @@ The suite is treated contract by contract. - **`Redistribution`** is not split. A new contract is deployed whenever the *game* must not be shared — a breaking Bee release (old and new Bee nodes cannot connect, so they must not share one on-chain game) or a Redistribution code change. Same bytecode - still gets a new address on a breaking Bee release. Cutover lands on a round boundary; - at most one redistributor is authorised at any block. + still gets a new address on a breaking Bee release. The redistributor pointer flips at + a round boundary; at most one redistributor is authorised at any block. - **`StakeRegistry`** splits into `StakingCore` (deposits, exits) and `StakingPolicy` (overlay, height, effective stake, slash/freeze rules). Operators migrate stake once, - then deposits stay put across later cutovers. + then deposits stay put across later upgrades. - **`PostageStamp`** splits into `PostageAccounting` (balances, accumulator, pot, expiry ordering) and `PostagePolicy` (admissibility, depth rules, price submission). Batches - are seeded once, treasury-matched; after that they carry across later cutovers. + are seeded once, treasury-matched; after that they carry across later upgrades. - **`PriceOracle`** is not split. It is redeployed when adjustment rules change, and submits prices through `PostagePolicy` into the core's bounded `setPrice`. @@ -149,14 +149,14 @@ balances live in postage. Redistribution only *reads* those and *calls* `claimPo at it. 2. **`Redistribution` itself changed (Type A or B).** A bugfix, a new claim check, a different round length. There is no upgrade path, so that is also a new deployment. - If Bee's p2p protocol is unchanged this is Type B: operators already running a release - with both addresses keep earning across cutover. + If Bee's p2p protocol is unchanged this is Type B: operators run the Bee that contains + the new address, then the pointer flips. Anyone still on the old binary stops earning. Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjustment, or a staking-policy change that does not change how commits are built or verified. Those replace the other contracts; the game address stays if the game is the same. -**Cutover.** Incoming `Redistribution` accepts commits from the round-boundary block +**Pointer flip.** Incoming `Redistribution` accepts commits from the round-boundary block where the postage core's redistributor pointer is executed onward. The postage core authorises at most one redistributor address at a time. The pointer moves through `proposeRedistributor` / `executeRedistributor` under the timelock and only inside @@ -167,8 +167,8 @@ round rather than orphaning a committed node. `claimPot` reverts for any other c If the previous Bee network still needs to pay for data availability, the outgoing `Redistribution` MAY keep paying at a reduced, decaying rate. That pot MUST be moved -into the outgoing contract **before** cutover. After cutover it is never the authorised -pointer again. +into the outgoing contract **before** the pointer flips. After that it is never the +authorised pointer again. **Migration.** None. There is no user state to move. Operators run the Bee release that contains the new address. Until `PostageAccounting` exists, singleton authority is @@ -212,7 +212,7 @@ dodges penalties. **Eligibility.** `StakingPolicy` computes participation from `min(firstDepositBlock, preRegistrationBlock)`. Pre-registration is a zero-value -transaction an operator may send before a deposit or a cutover, so a mass restake does +transaction an operator may send before a deposit or a pointer flip, so a mass restake does not open a participation trough. **Accounts and nodes.** Deposits are per account; overlay mapping is policy-side. One @@ -221,14 +221,14 @@ committed stake exceeds the account's deposit. A slash reduces the account, and every overlay it backs. `StakingPolicy` SHOULD take an immutable `predecessor` and lazily inherit overlay and -height on first use, so a later cutover needs no operator transaction. +height on first use, so a later upgrade needs no operator transaction. **Migration (once).** Operators move deposits with today's `migrateStake()` onto `StakingCore`. The old registry is paused at `activationBlock`, not later. Operators SHOULD pre-register so they are eligible immediately. After this, stake does not move -again: later cutovers only replace `StakingPolicy`. +again: later upgrades only replace `StakingPolicy`. -**Cutover after the split.** Policy-pointer change on `StakingCore` under +**After the split.** Policy-pointer change on `StakingCore` under `POLICY_TIMELOCK`. Deposits, withdrawals and exits never change ABI. ### PostageStamp @@ -306,10 +306,10 @@ deposits, so the new core is seeded and separately backed: expire into the old pot, using `withdraw` with the treasury as beneficiary — the final announced use of that primitive. The tail equals the longest remaining batch life. -After this, batches do not migrate again. Later cutovers only replace `PostagePolicy` and +After this, batches do not migrate again. Later upgrades only replace `PostagePolicy` and `Redistribution`. -**Cutover after the split.** Redistributor pointer as in [Redistribution](#redistribution). +**After the split.** Redistributor pointer as in [Redistribution](#redistribution). Policy-pointer change under `POLICY_TIMELOCK`. Balance reads never change ABI. **Residual trust.** Deposits cannot be stolen or flash-drained. A hostile policy can @@ -337,11 +337,11 @@ MUST be compatible with the oracle's own steps, or honest adjustments revert. address. In-flight postage balances are unaffected: they are denominated in the core accumulator, not in the oracle. -### Cutover +### Releases -No extra contract. Bee ships the current addresses and ABIs in the binary, as it does -today. Operators switch by running that Bee. Governance flips the postage redistributor -pointer (and any policy pointer) at a round boundary of the outgoing game. +Bee ships the current addresses and ABIs in the binary, as it does today. Operators +switch by running that Bee. Governance flips the postage redistributor pointer (and any +policy pointer) at a round boundary of the outgoing game. A **breaking Bee release (Type A)** is a Bee version whose nodes cannot connect to the previous version. Ship a new `Redistribution` in that binary. Non-upgraded nodes stop @@ -350,36 +350,21 @@ commitment hashing, overlay derivation, eligibility, or stamp validity (includin `refundBatch`). A **contract-only release (Type B)** does not change Bee's p2p protocol. Still ship the -new addresses in Bee; still flip the pointer at a round boundary. There is no dual-ABI -mode: a node that has not upgraded is calling the retired address and stops earning once -the pointer has moved. That is the same operator duty as today, without overlapping -redistributors. +new addresses in Bee; still flip the pointer at a round boundary. A node that has not +upgraded is calling the retired address and stops earning once the pointer has moved. `activationBlock % ROUND_LENGTH_outgoing == 0`. A mid-round flip orphans commits. A `ROUND_LENGTH` change is Type A; the incoming game starts on an outgoing boundary. Clients MUST NOT send a fund-moving transaction as an automated consequence of an upgrade or a chain event. -An on-chain `Cutover` registry, guarded proxies, and `pinnedExecute` are not used. They -would only duplicate the Bee release: addresses already live in the binary, and a chain -signal cannot be allowed to redirect funds. - ## Rationale Upgradeable proxies over fund-holding contracts are rejected. A proxy admin can steal -the funds. Checking a registry on every fallback taxes all calls and can revert -withdrawals on a mistaken deprecation. `pinnedExecute` adds a second delegatecall path -and a permanent selector-collision constraint. That machinery solves "the admin swapped -the implementation under me." If deposits live in a contract that cannot be swapped, the -event is no longer a fund-loss event. - -An on-chain `Cutover` contract is rejected for the same reason. Its only job would be -telling Bee *when* to switch addresses that Bee already compiled in. Type A does not -need that: old and new Bee cannot peer, and the new binary already has the new -`Redistribution`. Type B would need it only to run two ABIs at once without restarting. -That is not worth a new contract, a dual-mode client, and a rescheduling protocol. -Operators upgrade Bee; governance flips the pointer at a round boundary; anyone still -on the old binary stops earning. The trust root is the Bee release either way. +the funds. If deposits live in a contract that cannot be swapped, that event is no +longer a fund-loss event. Bee already compiles addresses into the binary; operators +upgrade Bee, governance flips the pointer at a round boundary, and anyone still on the +old binary stops earning. Full-suite redeployment at every breaking Bee release is rejected for the same reason the split exists. From 0d7f9952df2742c592b35faafabcf64d791e2dea Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:27:53 +0200 Subject: [PATCH 16/24] swip-67: explain POLICY_TIMELOCK, EXIT_DELAY, and activationBlock --- SWIPs/swip-67.md | 113 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 17 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 46bc5444..7e92815a 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -17,6 +17,7 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [Simple Summary](#simple-summary) · [Abstract](#abstract) - [Motivation](#motivation) - [Specification](#specification) + - [Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock) - [Redistribution](#redistribution) - [Staking](#staking) - [PostageStamp](#postagestamp) @@ -127,6 +128,80 @@ A core with a timelocked pointer is not admin-free. The claim is narrower: no pr operation can move a user's deposit, and every privileged operation is announced in advance with an exit window. +### Pointers, timelocks, and `activationBlock` + +Two clocks, two jobs. Do not mix them. + +**`POLICY_TIMELOCK` is on the cores.** `StakingCore` has one pointer: its `StakingPolicy`. +`PostageAccounting` has two: its `PostagePolicy` and its redistributor (`Redistribution`). +`Redistribution` and `PriceOracle` have neither a pointer nor a timelock; they are +replaced by deploying a new contract and, where needed, flipping a pointer *on a core*. + +The governing multisig is the only address that may propose or cancel a pointer change. +The core enforces the delay itself with an immutable block count (suggested: 14 days). +No external timelock contract. + +Sequence: + +1. **Propose.** Multisig calls `proposePolicy(next)` or `proposeRedistributor(next)` on + the core. The core stores `next` and `proposeBlock`, and emits an event. Nothing has + switched yet. +2. **Wait.** For `POLICY_TIMELOCK` blocks, the old pointer is still live. Users who + dislike `next` can `exit` or `refundBatch`. The multisig MAY `cancel*` during this + window; it MUST NOT shorten the delay. +3. **Execute.** After the delay, `executePolicy()` may be called. For the *redistributor* + pointer, execute is further restricted to + `[activationBlock, activationBlock + EXECUTION_WINDOW)` (below). Anyone MAY execute + once the window is open; only the current `next` is installed. After execute, `claimPot` + / policy-gated calls talk to the new address. + +Policy pointers (`StakingPolicy`, `PostagePolicy`) need only the timelock. They do not +touch an in-flight redistribution round. + +**`activationBlock` is the round-boundary at which the redistributor pointer may +execute.** It is not compiled into Bee and it is not read from a registry. Bee already +has the new `Redistribution` address. `activationBlock` is announced with the Bee +release (release notes / dashboard), and the *postage core* is what enforces it: + +- `activationBlock % ROUND_LENGTH == 0` for the *outgoing* game. Commit, reveal and + claim all sit inside one round; a flip mid-round orphans nodes that have committed. + Changing `ROUND_LENGTH` is Type A; the incoming game starts on an outgoing boundary. +- `activationBlock` MUST be at least `proposeBlock + POLICY_TIMELOCK`. Propose early + enough that the delay has elapsed by the chosen round boundary. +- `executeRedistributor()` reverts before `activationBlock` and after + `activationBlock + EXECUTION_WINDOW`. `EXECUTION_WINDOW` is a core constant well under + one round, so a late Safe transaction still lands in the same round rather than the + next. Until execute succeeds, the outgoing `Redistribution` remains authorised. + +Until `PostageAccounting` exists, there is no on-chain window: stage 1 honours the same +round boundary by operational discipline (one `REDISTRIBUTOR_ROLE`, flipped at a round +start). + +**`EXIT_DELAY` is not a governance timelock.** It is the unbonding wait on +`StakingCore.requestExit()` → `exit()`, paid to `msg.sender`. The staker starts it, not +the multisig. It MUST be at least the maximum freeze horizon, or exit dodges slashing. +`refundBatch` has no unbonding delay; the forfeit fraction is the brake. + +Worked order for a breaking Bee release: + +1. Deploy the new `Redistribution` (and new policy contracts if they change). +2. Multisig `proposeRedistributor` (and `proposePolicy` if needed) on the cores. +3. Ship Bee with the new addresses. Operators upgrade during the timelock. +4. At `activationBlock`, `executeRedistributor` (and `executePolicy`). Non-upgraded + nodes stop earning. + +```solidity +// On both cores +function proposePolicy(address next) external; +function cancelPolicy() external; +function executePolicy() external; + +// PostageAccounting only +function proposeRedistributor(address next) external; +function cancelRedistributor() external; +function executeRedistributor() external; +``` + ### Redistribution Not split. It holds no user deposits. @@ -156,14 +231,10 @@ Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjus a staking-policy change that does not change how commits are built or verified. Those replace the other contracts; the game address stays if the game is the same. -**Pointer flip.** Incoming `Redistribution` accepts commits from the round-boundary block -where the postage core's redistributor pointer is executed onward. The postage core -authorises at most one redistributor address at a time. The pointer moves through -`proposeRedistributor` / `executeRedistributor` under the timelock and only inside -`[activationBlock, activationBlock + EXECUTION_WINDOW)`, where `activationBlock` is a -round boundary of the outgoing game, announced with the Bee release. Until execution, -the outgoing contract remains authorised, so a late execution shortens the first new -round rather than orphaning a committed node. `claimPot` reverts for any other caller. +**Pointer flip.** The postage core holds the only redistributor pointer. See +[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +Incoming `Redistribution` accepts commits from `activationBlock` onward. `claimPot` +reverts for any caller that is not the current pointer. If the previous Bee network still needs to pay for data availability, the outgoing `Redistribution` MAY keep paying at a reduced, decaying rate. That pot MUST be moved @@ -195,6 +266,9 @@ interface IStakingCore { function exit() external; function slash(address account, uint256 amount) external; function lock(address account, uint64 until) external; + function proposePolicy(address next) external; + function cancelPolicy() external; + function executePolicy() external; function depositOf(address account) external view returns (uint256); function firstDepositBlock(address account) external view returns (uint64); function totalDeposited() external view returns (uint256); @@ -228,8 +302,9 @@ height on first use, so a later upgrade needs no operator transaction. SHOULD pre-register so they are eligible immediately. After this, stake does not move again: later upgrades only replace `StakingPolicy`. -**After the split.** Policy-pointer change on `StakingCore` under -`POLICY_TIMELOCK`. Deposits, withdrawals and exits never change ABI. +**After the split.** Policy-pointer change on `StakingCore` as in +[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +Deposits, withdrawals and exits never change ABI. ### PostageStamp @@ -262,6 +337,9 @@ interface IPostageAccounting { function topUp(bytes32 batchId, uint256 amountPerChunk) external; function refundBatch(bytes32 batchId) external; function expire(bytes32[] calldata batchIds) external; + function proposePolicy(address next) external; + function cancelPolicy() external; + function executePolicy() external; function proposeRedistributor(address next) external; function cancelRedistributor() external; function executeRedistributor() external; @@ -309,8 +387,9 @@ deposits, so the new core is seeded and separately backed: After this, batches do not migrate again. Later upgrades only replace `PostagePolicy` and `Redistribution`. -**After the split.** Redistributor pointer as in [Redistribution](#redistribution). -Policy-pointer change under `POLICY_TIMELOCK`. Balance reads never change ABI. +**After the split.** Redistributor and policy pointers as in +[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +Balance reads never change ABI. **Residual trust.** Deposits cannot be stolen or flash-drained. A hostile policy can still, after the timelock, claim the pot at up to the honest rate and bias who wins. @@ -340,8 +419,10 @@ accumulator, not in the oracle. ### Releases Bee ships the current addresses and ABIs in the binary, as it does today. Operators -switch by running that Bee. Governance flips the postage redistributor pointer (and any -policy pointer) at a round boundary of the outgoing game. +switch by running that Bee. Governance proposes pointer changes on the cores, then +executes them after `POLICY_TIMELOCK` — the redistributor pointer only at +`activationBlock`. Details: +[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). A **breaking Bee release (Type A)** is a Bee version whose nodes cannot connect to the previous version. Ship a new `Redistribution` in that binary. Non-upgraded nodes stop @@ -350,11 +431,9 @@ commitment hashing, overlay derivation, eligibility, or stamp validity (includin `refundBatch`). A **contract-only release (Type B)** does not change Bee's p2p protocol. Still ship the -new addresses in Bee; still flip the pointer at a round boundary. A node that has not +new addresses in Bee; still flip the pointer at `activationBlock`. A node that has not upgraded is calling the retired address and stops earning once the pointer has moved. -`activationBlock % ROUND_LENGTH_outgoing == 0`. A mid-round flip orphans commits. A -`ROUND_LENGTH` change is Type A; the incoming game starts on an outgoing boundary. Clients MUST NOT send a fund-moving transaction as an automated consequence of an upgrade or a chain event. From 1206323c608ab16da2e6f7313195896398c20c5b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:33:04 +0200 Subject: [PATCH 17/24] swip-67: activationBlock is set in proposeRedistributor, not implied --- SWIPs/swip-67.md | 70 +++++++++++++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 7e92815a..902a6439 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -143,9 +143,10 @@ No external timelock contract. Sequence: -1. **Propose.** Multisig calls `proposePolicy(next)` or `proposeRedistributor(next)` on - the core. The core stores `next` and `proposeBlock`, and emits an event. Nothing has - switched yet. +1. **Propose.** Multisig calls `proposePolicy(next)` or + `proposeRedistributor(next, activationBlock)` on the core. The core stores `next` + (and, for redistributor, `activationBlock`) and `proposeBlock`, and emits an event. + Nothing has switched yet. 2. **Wait.** For `POLICY_TIMELOCK` blocks, the old pointer is still live. Users who dislike `next` can `exit` or `refundBatch`. The multisig MAY `cancel*` during this window; it MUST NOT shorten the delay. @@ -158,24 +159,44 @@ Sequence: Policy pointers (`StakingPolicy`, `PostagePolicy`) need only the timelock. They do not touch an in-flight redistribution round. -**`activationBlock` is the round-boundary at which the redistributor pointer may -execute.** It is not compiled into Bee and it is not read from a registry. Bee already -has the new `Redistribution` address. `activationBlock` is announced with the Bee -release (release notes / dashboard), and the *postage core* is what enforces it: - -- `activationBlock % ROUND_LENGTH == 0` for the *outgoing* game. Commit, reveal and - claim all sit inside one round; a flip mid-round orphans nodes that have committed. - Changing `ROUND_LENGTH` is Type A; the incoming game starts on an outgoing boundary. -- `activationBlock` MUST be at least `proposeBlock + POLICY_TIMELOCK`. Propose early - enough that the delay has elapsed by the chosen round boundary. -- `executeRedistributor()` reverts before `activationBlock` and after - `activationBlock + EXECUTION_WINDOW`. `EXECUTION_WINDOW` is a core constant well under - one round, so a late Safe transaction still lands in the same round rather than the - next. Until execute succeeds, the outgoing `Redistribution` remains authorised. - -Until `PostageAccounting` exists, there is no on-chain window: stage 1 honours the same -round boundary by operational discipline (one `REDISTRIBUTOR_ROLE`, flipped at a round -start). +**`activationBlock` is set by the multisig. It does not happen by itself.** + +When the multisig proposes a new redistributor it also passes the block at which that +pointer is allowed to go live: + +```solidity +function proposeRedistributor(address next, uint64 activationBlock) external; +``` + +`PostageAccounting` stores that number. `executeRedistributor()` later checks +`block.number` against it. Bee never computes it; there is no registry to read it from. +Release notes may repeat the same number so operators know the upgrade deadline. + +The core accepts the proposal only if: + +- `activationBlock` is a round start: `activationBlock % ROUND_LENGTH == 0`. + `ROUND_LENGTH` is an immutable on `PostageAccounting`, matching the outgoing game + (152 today). Commit, reveal and claim all sit inside one round; a flip mid-round + orphans nodes that have committed. +- `activationBlock >= block.number + POLICY_TIMELOCK`, so the exit window is over + before the flip. The multisig picks a concrete future round (for example “round + starting at block 18_234_000”) that is far enough out, then proposes that value. + +Execute then only succeeds inside +`[activationBlock, activationBlock + EXECUTION_WINDOW)`. +`EXECUTION_WINDOW` is a core constant well under one round, so a slightly late Safe +transaction still lands in the same round. Before `activationBlock`, and after the +window, `executeRedistributor` reverts. Until execute succeeds, the outgoing +`Redistribution` remains authorised. + +Example: timelock is 14 days, rounds are 152 blocks, now is block 10_000_000. The +multisig must pick the first round start that is at least 14 days later, put that +block into `proposeRedistributor`, ship Bee, and call `executeRedistributor` in that +round. If they never propose, the pointer never moves. + +Changing `ROUND_LENGTH` is Type A; the incoming game starts on an outgoing boundary. +Until `PostageAccounting` exists, stage 1 honours the same round start by operational +discipline (one `REDISTRIBUTOR_ROLE`, flipped at a round start). **`EXIT_DELAY` is not a governance timelock.** It is the unbonding wait on `StakingCore.requestExit()` → `exit()`, paid to `msg.sender`. The staker starts it, not @@ -185,7 +206,8 @@ the multisig. It MUST be at least the maximum freeze horizon, or exit dodges sla Worked order for a breaking Bee release: 1. Deploy the new `Redistribution` (and new policy contracts if they change). -2. Multisig `proposeRedistributor` (and `proposePolicy` if needed) on the cores. +2. Multisig `proposeRedistributor(newRedistribution, activationBlock)` (and + `proposePolicy` if needed) on the cores. 3. Ship Bee with the new addresses. Operators upgrade during the timelock. 4. At `activationBlock`, `executeRedistributor` (and `executePolicy`). Non-upgraded nodes stop earning. @@ -197,7 +219,7 @@ function cancelPolicy() external; function executePolicy() external; // PostageAccounting only -function proposeRedistributor(address next) external; +function proposeRedistributor(address next, uint64 activationBlock) external; function cancelRedistributor() external; function executeRedistributor() external; ``` @@ -340,7 +362,7 @@ interface IPostageAccounting { function proposePolicy(address next) external; function cancelPolicy() external; function executePolicy() external; - function proposeRedistributor(address next) external; + function proposeRedistributor(address next, uint64 activationBlock) external; function cancelRedistributor() external; function executeRedistributor() external; function remainingBalance(bytes32 batchId) external view returns (uint256); From c3f14e97cd09d2f2e4ad67b853f90c9d209c1d09 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:36:56 +0200 Subject: [PATCH 18/24] swip-67: drop stored activationBlock; execute at any later round start --- SWIPs/swip-67.md | 126 ++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 77 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 902a6439..26b41770 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -17,7 +17,7 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [Simple Summary](#simple-summary) · [Abstract](#abstract) - [Motivation](#motivation) - [Specification](#specification) - - [Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock) + - [Pointers and timelocks](#pointers-and-timelocks) - [Redistribution](#redistribution) - [Staking](#staking) - [PostageStamp](#postagestamp) @@ -128,7 +128,7 @@ A core with a timelocked pointer is not admin-free. The claim is narrower: no pr operation can move a user's deposit, and every privileged operation is announced in advance with an exit window. -### Pointers, timelocks, and `activationBlock` +### Pointers and timelocks Two clocks, two jobs. Do not mix them. @@ -141,62 +141,33 @@ The governing multisig is the only address that may propose or cancel a pointer The core enforces the delay itself with an immutable block count (suggested: 14 days). No external timelock contract. -Sequence: - -1. **Propose.** Multisig calls `proposePolicy(next)` or - `proposeRedistributor(next, activationBlock)` on the core. The core stores `next` - (and, for redistributor, `activationBlock`) and `proposeBlock`, and emits an event. - Nothing has switched yet. -2. **Wait.** For `POLICY_TIMELOCK` blocks, the old pointer is still live. Users who - dislike `next` can `exit` or `refundBatch`. The multisig MAY `cancel*` during this - window; it MUST NOT shorten the delay. -3. **Execute.** After the delay, `executePolicy()` may be called. For the *redistributor* - pointer, execute is further restricted to - `[activationBlock, activationBlock + EXECUTION_WINDOW)` (below). Anyone MAY execute - once the window is open; only the current `next` is installed. After execute, `claimPot` - / policy-gated calls talk to the new address. - -Policy pointers (`StakingPolicy`, `PostagePolicy`) need only the timelock. They do not -touch an in-flight redistribution round. - -**`activationBlock` is set by the multisig. It does not happen by itself.** +There is **no stored `activationBlock`.** Picking the flip date at propose time is what +goes wrong when Bee slips: you miss a one-round window and have to propose again, another +full timelock. The date is chosen at **execute** time, once the binary is actually out. -When the multisig proposes a new redistributor it also passes the block at which that -pointer is allowed to go live: - -```solidity -function proposeRedistributor(address next, uint64 activationBlock) external; -``` - -`PostageAccounting` stores that number. `executeRedistributor()` later checks -`block.number` against it. Bee never computes it; there is no registry to read it from. -Release notes may repeat the same number so operators know the upgrade deadline. - -The core accepts the proposal only if: - -- `activationBlock` is a round start: `activationBlock % ROUND_LENGTH == 0`. - `ROUND_LENGTH` is an immutable on `PostageAccounting`, matching the outgoing game - (152 today). Commit, reveal and claim all sit inside one round; a flip mid-round - orphans nodes that have committed. -- `activationBlock >= block.number + POLICY_TIMELOCK`, so the exit window is over - before the flip. The multisig picks a concrete future round (for example “round - starting at block 18_234_000”) that is far enough out, then proposes that value. - -Execute then only succeeds inside -`[activationBlock, activationBlock + EXECUTION_WINDOW)`. -`EXECUTION_WINDOW` is a core constant well under one round, so a slightly late Safe -transaction still lands in the same round. Before `activationBlock`, and after the -window, `executeRedistributor` reverts. Until execute succeeds, the outgoing -`Redistribution` remains authorised. - -Example: timelock is 14 days, rounds are 152 blocks, now is block 10_000_000. The -multisig must pick the first round start that is at least 14 days later, put that -block into `proposeRedistributor`, ship Bee, and call `executeRedistributor` in that -round. If they never propose, the pointer never moves. +Sequence: -Changing `ROUND_LENGTH` is Type A; the incoming game starts on an outgoing boundary. -Until `PostageAccounting` exists, stage 1 honours the same round start by operational -discipline (one `REDISTRIBUTOR_ROLE`, flipped at a round start). +1. **Propose.** Multisig calls `proposePolicy(next)` or `proposeRedistributor(next)`. + The core stores `next` and `proposeBlock`, and emits an event. Nothing has switched + yet. The old `Redistribution` keeps paying. +2. **Wait.** For `POLICY_TIMELOCK` blocks, users who dislike `next` can `exit` or + `refundBatch`. The multisig MAY cancel during this window; it MUST NOT shorten the + delay. If Bee is late, **do nothing** — the old game continues, or pause it and + accept a gap. That gap already happens today. It is not a reason to re-propose. +3. **Execute**, after the delay, when Bee is out. `executePolicy()` has no extra clock. + `executeRedistributor()` also requires the current block to be in the opening of a + round: `block.number % ROUND_LENGTH < EXECUTION_WINDOW`. `ROUND_LENGTH` is an + immutable on `PostageAccounting` (152 today). Any later round start is valid; there + is no missed date. A mid-round execute reverts, so committed nodes are not orphaned. + Anyone MAY execute once those checks pass; only the proposed `next` is installed. + +Do **not** let the multisig `setRedistributor` in one shot with no propose step. That +drops the exit window, which is the whole point of the timelock. Round alignment +without a pre-committed block is enough to avoid a mid-round flip; a gap with nobody +playing is acceptable. + +Until `PostageAccounting` exists, stage 1 honours the same rule by operational +discipline: one `REDISTRIBUTOR_ROLE`, flipped at a round start after Bee is out. **`EXIT_DELAY` is not a governance timelock.** It is the unbonding wait on `StakingCore.requestExit()` → `exit()`, paid to `msg.sender`. The staker starts it, not @@ -206,11 +177,11 @@ the multisig. It MUST be at least the maximum freeze horizon, or exit dodges sla Worked order for a breaking Bee release: 1. Deploy the new `Redistribution` (and new policy contracts if they change). -2. Multisig `proposeRedistributor(newRedistribution, activationBlock)` (and - `proposePolicy` if needed) on the cores. -3. Ship Bee with the new addresses. Operators upgrade during the timelock. -4. At `activationBlock`, `executeRedistributor` (and `executePolicy`). Non-upgraded - nodes stop earning. +2. Multisig `proposeRedistributor(newRedistribution)` (and `proposePolicy` if needed). +3. Ship Bee. Operators upgrade during the timelock. If the release slips, wait; the + old pointer stays live (or pause the old game). +4. After the timelock, at the next round start, `executeRedistributor` (and + `executePolicy`). Non-upgraded nodes stop earning. ```solidity // On both cores @@ -219,7 +190,7 @@ function cancelPolicy() external; function executePolicy() external; // PostageAccounting only -function proposeRedistributor(address next, uint64 activationBlock) external; +function proposeRedistributor(address next) external; function cancelRedistributor() external; function executeRedistributor() external; ``` @@ -254,8 +225,8 @@ a staking-policy change that does not change how commits are built or verified. replace the other contracts; the game address stays if the game is the same. **Pointer flip.** The postage core holds the only redistributor pointer. See -[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). -Incoming `Redistribution` accepts commits from `activationBlock` onward. `claimPot` +[Pointers and timelocks](#pointers-and-timelocks). +Incoming `Redistribution` accepts commits once it is the authorised pointer. `claimPot` reverts for any caller that is not the current pointer. If the previous Bee network still needs to pay for data availability, the outgoing @@ -320,12 +291,12 @@ every overlay it backs. height on first use, so a later upgrade needs no operator transaction. **Migration (once).** Operators move deposits with today's `migrateStake()` onto -`StakingCore`. The old registry is paused at `activationBlock`, not later. Operators +`StakingCore`. The old registry is paused when the pointer executes, not later. Operators SHOULD pre-register so they are eligible immediately. After this, stake does not move again: later upgrades only replace `StakingPolicy`. **After the split.** Policy-pointer change on `StakingCore` as in -[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +[Pointers and timelocks](#pointers-and-timelocks). Deposits, withdrawals and exits never change ABI. ### PostageStamp @@ -362,7 +333,7 @@ interface IPostageAccounting { function proposePolicy(address next) external; function cancelPolicy() external; function executePolicy() external; - function proposeRedistributor(address next, uint64 activationBlock) external; + function proposeRedistributor(address next) external; function cancelRedistributor() external; function executeRedistributor() external; function remainingBalance(bytes32 batchId) external view returns (uint256); @@ -395,10 +366,10 @@ A: stamp validity is consensus-adjacent. **Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired deposits, so the new core is seeded and separately backed: -1. At `activationBlock`, `PostageStamp` is paused (`createBatch`, `topUp`, +1. When the new core goes live, `PostageStamp` is paused (`createBatch`, `topUp`, `increaseDepth` freeze; expiry and `withdraw` continue). 2. `PostageAccounting` is deployed in a genesis phase. The deployer seeds the batch set - as of `activationBlock` and transfers in matching BZZ for the full seeded value. The + as of that block and transfers in matching BZZ for the full seeded value. The treasury fronts this float. 3. Genesis is sealed in the same ceremony. Until sealed, no other call is accepted; after sealing, no seeding path exists. @@ -410,7 +381,7 @@ After this, batches do not migrate again. Later upgrades only replace `PostagePo `Redistribution`. **After the split.** Redistributor and policy pointers as in -[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +[Pointers and timelocks](#pointers-and-timelocks). Balance reads never change ABI. **Residual trust.** Deposits cannot be stolen or flash-drained. A hostile policy can @@ -442,9 +413,9 @@ accumulator, not in the oracle. Bee ships the current addresses and ABIs in the binary, as it does today. Operators switch by running that Bee. Governance proposes pointer changes on the cores, then -executes them after `POLICY_TIMELOCK` — the redistributor pointer only at -`activationBlock`. Details: -[Pointers, timelocks, and `activationBlock`](#pointers-timelocks-and-activationblock). +executes them after `POLICY_TIMELOCK`. The redistributor pointer may execute only at a +round start, whichever round comes after Bee is out. Details: +[Pointers and timelocks](#pointers-and-timelocks). A **breaking Bee release (Type A)** is a Bee version whose nodes cannot connect to the previous version. Ship a new `Redistribution` in that binary. Non-upgraded nodes stop @@ -453,7 +424,7 @@ commitment hashing, overlay derivation, eligibility, or stamp validity (includin `refundBatch`). A **contract-only release (Type B)** does not change Bee's p2p protocol. Still ship the -new addresses in Bee; still flip the pointer at `activationBlock`. A node that has not +new addresses in Bee; still flip the pointer at a round start after the timelock. A node that has not upgraded is calling the retired address and stops earning once the pointer has moved. Clients MUST NOT send a fund-moving transaction as an automated consequence of an @@ -498,12 +469,13 @@ Mandatory before any core deployment. (including policy = 0, pending pointer change, and a locked staking account). - Genesis: seeding without matching BZZ reverts; any call before seal reverts; seeding after seal reverts from every role; conservation holds at the first open block. -- Pointer changes cannot execute before `POLICY_TIMELOCK` or outside the execution - window; cancellation works only before execution. +- Pointer changes cannot execute before `POLICY_TIMELOCK`. Redistributor execute + additionally reverts unless the current block is in the opening of a round. + Cancellation works only before execution. - A pointer flip off a round boundary reverts; a boundary-aligned flip does not orphan a commit. - Policy replacement does not change `currentTotalOutPayment` or remaining balances. -- Pre-registered operators are eligible at `activationBlock`; others are not. +- Pre-registered operators are eligible when the pointer executes; others are not. - Fuzz randomised sequences of deposit, fund, top-up, resize, price, expire, claim, slash, refund, withdraw and exit. From 282b91826bd4ed2da2cf185148566dddc5646775 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:49:40 +0200 Subject: [PATCH 19/24] swip-67: adopt storage-incentives#309 as the staking lifecycle Use the queued withdraw/exit workflow instead of a parallel requestExit design, and keep the custody split of that contract. --- SWIPs/swip-67.md | 130 ++++++++++++++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 47 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 26b41770..2c696730 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -60,9 +60,12 @@ The suite is treated contract by contract. they must not share one on-chain game) or a Redistribution code change. Same bytecode still gets a new address on a breaking Bee release. The redistributor pointer flips at a round boundary; at most one redistributor is authorised at any block. -- **`StakeRegistry`** splits into `StakingCore` (deposits, exits) and `StakingPolicy` - (overlay, height, effective stake, slash/freeze rules). Operators migrate stake once, - then deposits stay put across later upgrades. +- **`StakeRegistry`** splits into `StakingCore` (BZZ, the #309 queue, freeze, payouts) + and `StakingPolicy` (overlay derivation, height / `MIN_STAKE`, eligibility views). The + staking *lifecycle* is + [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) + (queued deposit / top-up / height / overlay / withdraw / exit), not a second unbonding + design. Operators migrate stake once, then deposits stay put across later upgrades. - **`PostageStamp`** splits into `PostageAccounting` (balances, accumulator, pot, expiry ordering) and `PostagePolicy` (admissibility, depth rules, price submission). Batches are seeded once, treasury-matched; after that they carry across later upgrades. @@ -122,7 +125,10 @@ Shared rules for the two cores (`StakingCore`, `PostageAccounting`): `claimPot`, `slash`, `setPrice`. - Policy and redistributor pointers change only after `POLICY_TIMELOCK`, enforced by the core; a pending change is cancellable. -- `exit` / `refundBatch` have no role check, no pause, and ignore policy locks. +- `refundBatch` has no role check, no pause, and ignores policy locks. Staking + payout is [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309): + a matured `withdraw` / `exit` pays `msg.sender` via `applyUpdates`. Redistribution freeze + can delay that payout (`FrozenWithdrawal`). An admin pause or a hostile policy cannot. A core with a timelocked pointer is not admin-free. The claim is narrower: no privileged operation can move a user's deposit, and every privileged operation is announced in @@ -169,10 +175,12 @@ playing is acceptable. Until `PostageAccounting` exists, stage 1 honours the same rule by operational discipline: one `REDISTRIBUTOR_ROLE`, flipped at a round start after Bee is out. -**`EXIT_DELAY` is not a governance timelock.** It is the unbonding wait on -`StakingCore.requestExit()` → `exit()`, paid to `msg.sender`. The staker starts it, not -the multisig. It MUST be at least the maximum freeze horizon, or exit dodges slashing. -`refundBatch` has no unbonding delay; the forfeit fraction is the brake. +**`WAIT_WITHDRAWAL` is not a governance timelock.** It is the unbonding wait in +[storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) +before a queued `withdraw` or `exit` can pay `msg.sender`. The staker starts it, not the +multisig. Freeze from Redistribution can delay that payout further (same as #309), so +exit cannot dodge an in-flight penalty. `refundBatch` has no unbonding delay; the forfeit +fraction is the brake. Worked order for a breaking Bee release: @@ -202,7 +210,7 @@ Not split. It holds no user deposits. **State.** Commits, reveals, round counters and the last winner. None of it is worth preserving across a release. Overlay, stake and freeze data live in staking; postage balances live in postage. Redistribution only *reads* those and *calls* `claimPot` / -`slash` / `lock`. +`slash` / `freezeDeposit`. **How it is updated.** Redeploy when the incentive game must not be shared. Two triggers: @@ -243,61 +251,84 @@ postage split. Split. `StakeRegistry` becomes `StakingCore` + `StakingPolicy`. +Today's registry has surplus `withdrawFromStake` and a paused `migrateStake`. It has no +real unstake. Do not invent a second one (`requestExit` / `EXIT_DELAY` / +pre-registration). The staking *lifecycle* is +[storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309): +one queued update per overlay (deposit, top-up, height, overlay, **withdraw**, **exit**), +`WAIT_BASE` before a new deposit can play, `WAIT_OVERLAY_CHANGE` / +`WAIT_WITHDRAWAL` (~28 days on production) before those updates apply, freeze that stays +on the account after unstake, and effective stake = balance unless frozen, else zero. +Views preview the post-apply state; BZZ moves only in `applyUpdates`. + +#309 is still one contract. This SWIP only splits that contract. The queue mixes +withdrawals with overlay and height changes, so the queue, `WAIT_*`, `freezeUntilBlock`, +and BZZ stay together in the core. Otherwise a hostile policy can refuse `applyUpdates` +and trap funds, which is the same hatch #309 still has via `pause` / `migrateStake`. + | Stays in `StakingCore` (frozen, holds BZZ) | Moves to `StakingPolicy` (replaceable) | |---|---| -| Per-account deposit, `firstDepositBlock`, withdrawal and exit accounting | Overlay derivation, height, committed stake, effective stake, freeze and slash rules | +| Per-account BZZ, the #309 update queue, `WAIT_*`, `freezeUntilBlock`, `applyUpdates` payouts, slash | Overlay derivation (`NetworkId`), height / `MIN_STAKE` rules, effective-stake and lookahead views Redistribution and Bee call | -`StakingCore` MUST NOT store overlays, heights, committed stake or effective stake, and -MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so it is redeployed with a -breaking Bee release; deposits are not. +`StakingCore` MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so derivation is +policy and is replaced with a breaking Bee release; deposits are not. The core stores the +overlay *bytes* and height as #309 does, so there is still one FIFO. Policy supplies +derivation and the replaceable view API. ```solidity interface IStakingCore { - function deposit(uint256 amount) external; - function withdraw(uint256 amount) external; - function requestExit() external; - function exit() external; + function createDeposit(bytes32 overlay, uint256 amount, uint8 height) external returns (uint64); + function addTokens(uint256 amount) external returns (uint64); + function increaseHeight(uint8 height) external returns (uint64); + function changeOverlay(bytes32 overlay) external returns (uint64); + function withdraw(uint256 amount) external returns (uint64); + function exit() external returns (uint64); + function applyUpdates(address owner) external; function slash(address account, uint256 amount) external; - function lock(address account, uint64 until) external; + function freezeDeposit(address account, uint256 time) external; function proposePolicy(address next) external; function cancelPolicy() external; function executePolicy() external; function depositOf(address account) external view returns (uint256); - function firstDepositBlock(address account) external view returns (uint64); function totalDeposited() external view returns (uint256); } ``` -`deposit` credits `msg.sender` and records `firstDepositBlock` on the first credit. No -policy call. `withdraw` pays `msg.sender` only, and `lock` can block it. `exit` cannot -be locked, paused, or routed through policy; it is callable `EXIT_DELAY` blocks after -`requestExit()`. `slash` burns in place and is capped per window. +Bee still speaks #309 (`nonce` in, `effectiveFromRound` out). Overlay derivation and +`MIN_STAKE` checks live on the policy, which forwards into the core, so the core never +reads policy. `addTokens`, `exit`, and `applyUpdates` are permissionless. `applyUpdates` +pays the owner only, once `WAIT_WITHDRAWAL` has elapsed, unless `FrozenWithdrawal`. +`freezeDeposit` is `onlyRedistributor` and monotonic; it survives exit on this core +(same as #309). `slash` is `onlyRedistributor` and burns in place. `WAIT_WITHDRAWAL` +MUST be at least the longest Redistribution freeze, or a queued exit lands before the +penalty. -`exit()` is withdrawable stake. Today only surplus above committed stake can leave. -`EXIT_DELAY` MUST be at least the maximum freeze horizon the game can impose, or exit -dodges penalties. +No `pause` on the core. #309's `whenNotPaused` on `withdraw` / `exit` is the admin hatch +this split removes. -**Eligibility.** `StakingPolicy` computes participation from -`min(firstDepositBlock, preRegistrationBlock)`. Pre-registration is a zero-value -transaction an operator may send before a deposit or a pointer flip, so a mass restake does -not open a participation trough. +**Eligibility.** #309's `WAIT_BASE` after `createDeposit`. No parallel +`firstDepositBlock` / pre-registration clock. Overlay change uses `WAIT_OVERLAY_CHANGE` +and does not reset the deposit. Height still scales `MIN_STAKE`; the token amount in the +core is deposited BZZ, not a committed/potential pair. -**Accounts and nodes.** Deposits are per account; overlay mapping is policy-side. One -account may back several nodes. `StakingPolicy` MUST NOT admit overlays whose summed -committed stake exceeds the account's deposit. A slash reduces the account, and therefore -every overlay it backs. +**Accounts and nodes.** Deposits are per account. One account may back several nodes +only if policy admits that; summed usable stake MUST NOT exceed the account's deposit. +A slash reduces the account, and therefore every overlay it backs. `StakingPolicy` SHOULD take an immutable `predecessor` and lazily inherit overlay and height on first use, so a later upgrade needs no operator transaction. -**Migration (once).** Operators move deposits with today's `migrateStake()` onto -`StakingCore`. The old registry is paused when the pointer executes, not later. Operators -SHOULD pre-register so they are eligible immediately. After this, stake does not move -again: later upgrades only replace `StakingPolicy`. +**Migration (once).** Ship or adopt #309 on the current registry first if that lifecycle +is not live. `migrateStake` (paused in #309, used while paused) is the one jump onto +`StakingCore`. The old registry is paused for that jump, not kept as an admin path on +the core. After this, stake does not move again: later upgrades only replace +`StakingPolicy`. Operators who skip the jump unstake on the old registry with #309's +`withdraw` / `exit`, or take that one-shot `migrateStake` if the registry is being +retired. **After the split.** Policy-pointer change on `StakingCore` as in [Pointers and timelocks](#pointers-and-timelocks). -Deposits, withdrawals and exits never change ABI. +The #309 enqueue / `applyUpdates` ABI is what Bee talks to for deposits and payouts. ### PostageStamp @@ -465,8 +496,10 @@ Mandatory before any core deployment. - No transfer to an address not derived from core state (static check on bytecode). - No core call into the policy address. - Malicious policy: flash-drain, unbounded slash, over-claim, unbacked fund, value-inflating - resize, blocked exit, over-max price. All revert; `exit` / `refundBatch` still succeed - (including policy = 0, pending pointer change, and a locked staking account). + resize, blocked exit, over-max price. All revert; a matured staking `applyUpdates` + and `refundBatch` still succeed (including policy = 0 and a pending pointer change). + `FrozenWithdrawal` delays a due withdraw/exit until the redistributor freeze ends, then + payout succeeds. - Genesis: seeding without matching BZZ reverts; any call before seal reverts; seeding after seal reverts from every role; conservation holds at the first open block. - Pointer changes cannot execute before `POLICY_TIMELOCK`. Redistributor execute @@ -475,7 +508,8 @@ Mandatory before any core deployment. - A pointer flip off a round boundary reverts; a boundary-aligned flip does not orphan a commit. - Policy replacement does not change `currentTotalOutPayment` or remaining balances. -- Pre-registered operators are eligible when the pointer executes; others are not. +- A deposit is eligible after `WAIT_BASE`; overlay change after `WAIT_OVERLAY_CHANGE`; + withdraw/exit payout after `WAIT_WITHDRAWAL` and not while frozen. - Fuzz randomised sequences of deposit, fund, top-up, resize, price, expire, claim, slash, refund, withdraw and exit. @@ -487,7 +521,7 @@ Each stage is independently valuable and independently revertible. |---|---|---| | 1 | New `Redistribution`; round-aligned pointer flip; one redistributor by operational discipline | — | | 2 | New `Redistribution` on every breaking Bee release, as standing practice | — | -| 3 | `StakingCore` + `StakingPolicy`. Final stake migration | 2 | +| 3 | Adopt [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) if not live, then `StakingCore` + `StakingPolicy`. Final stake migration. No pause on the core | 2 | | 4 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 3 | | 5 | Multisig scope reduced to policy and redistributor pointers | 4 | @@ -499,10 +533,12 @@ coexist. ## Open questions -1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), `EXIT_DELAY` - (≥ maximum freeze horizon), `EXECUTION_WINDOW`, slash and pot windows, `MAX_PRICE` - and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's steps). Immutable once - deployed. +1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), + `WAIT_WITHDRAWAL` / `WAIT_BASE` / `WAIT_OVERLAY_CHANGE` from + [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) + (`WAIT_WITHDRAWAL` ≥ maximum freeze horizon), `EXECUTION_WINDOW`, slash and pot + windows, `MAX_PRICE` and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's + steps). Immutable once deployed. 2. **Refund economics.** `refundBatch` forfeit fraction, and the wind-down decay schedule. A forfeit is preferred over a minimum batch age. 3. **Treasury float.** Size of the genesis front, and whether a deadline caps the From 7f9ec70e1cae9b474b65bd2bb25b8c73adc06c5b Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 17:53:48 +0200 Subject: [PATCH 20/24] swip-67: align summary and abstract with pointer and freeze rules Policy execute is not round-aligned; Redistribution freeze can delay a staking payout, admin pause cannot. --- SWIPs/swip-67.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 2c696730..840e360e 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -39,17 +39,17 @@ place. That single fact causes both of our recurring problems: This SWIP splits the two fund-holding contracts — `PostageStamp` and `StakeRegistry` — each into a **core** and a **policy**. The core holds the money, has no admin power over -it, is never upgraded, and enforces its own accounting invariants. The policy holds the -rules, is freely replaceable, and can never name a payment destination. +deposits, is never upgraded, and enforces its own accounting invariants. The policy holds +the rules, is freely replaceable, and can never name a payment destination. `Redistribution` and `PriceOracle` hold no user deposits, so they are not split. They stay replaceable and are redeployed as-is. A new `Redistribution` whenever the on-chain game must not be shared — a breaking Bee release (even if the Solidity is unchanged) or a change to Redistribution itself. A new `PriceOracle` when its adjustment rules change. -It then specifies how a new `Redistribution` and a new policy are pointed in at a round -boundary, so a protocol upgrade stops being a fund movement. Bee keeps shipping contract -addresses in the binary, as it does today. +It then specifies how those pointers flip after a core-enforced timelock — the +redistributor only at a round start — so a protocol upgrade stops being a fund movement. +Bee keeps shipping contract addresses in the binary, as it does today. ## Abstract @@ -73,8 +73,9 @@ The suite is treated contract by contract. submits prices through `PostagePolicy` into the core's bounded `setPrice`. Cores hold all user BZZ. No core function transfers to a caller-supplied address. Pointers -change only after a core-enforced timelock. Exits cannot be paused. Contract addresses -stay compiled into Bee, as they are today. +change only after a core-enforced timelock. There is no admin pause on `refundBatch` or a +matured staking payout; a Redistribution freeze can still delay the latter. Contract +addresses stay compiled into Bee, as they are today. ## Motivation From 91d36a225cb4f8f8b5abf18cf8d7bfb40371c9e9 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 18:32:38 +0200 Subject: [PATCH 21/24] swip-67: close open questions Refund forfeits a slice to the pot; postage genesis is a self-top-up plus withdraw from the stopped contract; a later pricing model is a new core. --- SWIPs/swip-67.md | 52 ++++++++++++++++++++---------------------------- 1 file changed, 22 insertions(+), 30 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 840e360e..fb2b4e88 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -24,7 +24,7 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [PriceOracle](#priceoracle) - [Releases](#releases) - [Rationale](#rationale) -- [Test cases](#test-cases) · [Implementation](#implementation) · [Open questions](#open-questions) +- [Test cases](#test-cases) · [Implementation](#implementation) ## Simple Summary @@ -145,8 +145,12 @@ Two clocks, two jobs. Do not mix them. replaced by deploying a new contract and, where needed, flipping a pointer *on a core*. The governing multisig is the only address that may propose or cancel a pointer change. -The core enforces the delay itself with an immutable block count (suggested: 14 days). -No external timelock contract. +The core enforces the delay itself with an immutable block count. Suggested: +`POLICY_TIMELOCK` = 14 days in blocks. The exact value, and the other immutables +(`WAIT_*` from [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309), +`EXECUTION_WINDOW`, slash and pot windows, `MAX_PRICE` / +`MAX_PRICE_CHANGE_PER_UPDATE`, `refundBatch` forfeit), are decided before the cores +are deployed. No external timelock contract. There is **no stored `activationBlock`.** Picking the flip date at propose time is what goes wrong when Bee slips: you miss a one-round window and have to propose again, another @@ -347,8 +351,9 @@ MUST NOT settle while an expired batch is still counted — which is why the cor policy, owns the minimum-balance index. A policy-side accumulator would rebase every batch on every policy replacement. -The outpayment model is therefore frozen: linear per-block accrual against a per-chunk -normalised balance. A different model is not a policy change; it would need a new core. +The outpayment model is frozen in the core: linear per-block accrual against a per-chunk +normalised balance. A later pricing model is not a policy change and is not solved here; +it needs a new core. ```solidity interface IPostageAccounting { @@ -391,9 +396,12 @@ is capped per window. The cap limits *acceleration*; the honest game already pay whole pot each round. Protection against a hostile redistributor is the timelock plus `refundBatch`. -`refundBatch` pays the recorded owner and SHOULD forfeit a fraction to the pot. Nodes -treat a refund as batch invalidation, same as expiry. Introducing `refundBatch` is Type -A: stamp validity is consensus-adjacent. +`refundBatch` pays the recorded owner **less than** remaining balance. The difference is +a forfeit to the pot — the owner can take funds out, just a bit less, to cover leftover +pot operation and remaining availability. There is no minimum batch age. The forfeit +fraction is an immutable, chosen before deployment. Nodes treat a refund as batch +invalidation, same as expiry. Introducing `refundBatch` is Type A: stamp validity is +consensus-adjacent. **Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired deposits, so the new core is seeded and separately backed: @@ -401,13 +409,14 @@ deposits, so the new core is seeded and separately backed: 1. When the new core goes live, `PostageStamp` is paused (`createBatch`, `topUp`, `increaseDepth` freeze; expiry and `withdraw` continue). 2. `PostageAccounting` is deployed in a genesis phase. The deployer seeds the batch set - as of that block and transfers in matching BZZ for the full seeded value. The - treasury fronts this float. + as of that block. Matching BZZ is topped up on the new core by the operators of the + migration (not a user-by-user movement). 3. Genesis is sealed in the same ceremony. Until sealed, no other call is accepted; after sealing, no seeding path exists. -4. The treasury is reimbursed from the old contract as seeded batches' old-side balances - expire into the old pot, using `withdraw` with the treasury as beneficiary — the final - announced use of that primitive. The tail equals the longest remaining batch life. +4. After the old contract is stopped, remaining BZZ is withdrawn from it + (`withdraw` with the migration operators as beneficiary) — the final announced use of + that primitive. That withdraw, plus the top-up of the new core, is the float. There + is no open reimbursement-tail problem. After this, batches do not migrate again. Later upgrades only replace `PostagePolicy` and `Redistribution`. @@ -532,23 +541,6 @@ release that needs the new adjustment rules. After stage 4, surgical redeployment and the absence of admin power over deposits coexist. -## Open questions - -1. **Parameter values.** `POLICY_TIMELOCK` (suggested: 14 days in blocks), - `WAIT_WITHDRAWAL` / `WAIT_BASE` / `WAIT_OVERLAY_CHANGE` from - [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) - (`WAIT_WITHDRAWAL` ≥ maximum freeze horizon), `EXECUTION_WINDOW`, slash and pot - windows, `MAX_PRICE` and `MAX_PRICE_CHANGE_PER_UPDATE` (must match the oracle's - steps). Immutable once deployed. -2. **Refund economics.** `refundBatch` forfeit fraction, and the wind-down decay - schedule. A forfeit is preferred over a minimum batch age. -3. **Treasury float.** Size of the genesis front, and whether a deadline caps the - reimbursement tail. -4. **Multi-client discipline.** What conformance looks like if a second client exists. -5. **Frozen outpayment model.** Keep linear per-chunk accrual in the core, or freeze - remaining-BZZ with rate-capped policy consumption instead, so a later pricing model - is still a policy change. - ## Copyright Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From b027283f2b4c0b70d1fc3b4711a61a7b1eee2c47 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Tue, 8 Sep 2026 18:33:24 +0200 Subject: [PATCH 22/24] swip-67: put Rationale after Motivation Rejected alternatives belong with the problem statement, before the specification. --- SWIPs/swip-67.md | 54 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index fb2b4e88..641eb2b6 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -16,6 +16,7 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [Simple Summary](#simple-summary) · [Abstract](#abstract) - [Motivation](#motivation) +- [Rationale](#rationale) - [Specification](#specification) - [Pointers and timelocks](#pointers-and-timelocks) - [Redistribution](#redistribution) @@ -23,7 +24,6 @@ funds. Bee keeps shipping addresses in the binary. Pointers flip at a round boun - [PostageStamp](#postagestamp) - [PriceOracle](#priceoracle) - [Releases](#releases) -- [Rationale](#rationale) - [Test cases](#test-cases) · [Implementation](#implementation) ## Simple Summary @@ -110,6 +110,32 @@ Bee nodes share chunks: after a breaking Bee release they cannot peer, so bucket and local chunk state stay on each network. On-chain batches carry across; that local state does not. +## Rationale + +Upgradeable proxies over fund-holding contracts are rejected. A proxy admin can steal +the funds. If deposits live in a contract that cannot be swapped, that event is no +longer a fund-loss event. Bee already compiles addresses into the binary; operators +upgrade Bee, governance flips the pointer at a round boundary, and anyone still on the +old binary stops earning. + +Full-suite redeployment at every breaking Bee release is rejected for the same reason the +split exists. +It requires a batch migration every time, leaves that migration undesigned, and relies +on an incentive that does not hold: operators move stake to keep earning, but a user who +fails to move a batch loses availability they may not notice. After the two one-time +migrations in this SWIP, later upgrades replace policy and `Redistribution` only. + +An immutable policy pointer is stronger and useless: changing policy would mean a new +core, which is another migration. An external timelock is weaker: whoever replaces its +owner shortens the window. Putting expiry ordering in postage policy would make +conservation depend on policy honesty. User-driven `fund()` as the primary batch +migration is rejected because the backing BZZ is locked in `PostageStamp`. + +A policy with no authority over funds cannot slash and cannot pay winners. The +achievable goal is bounded, announced, visible authority with a usable exit. Creation +is policy-gated because admissibility changes per Bee release; exits are not, because a +hostile policy must not trap existing funds. + ## Specification Shared rules for the two cores (`StakingCore`, `PostageAccounting`): @@ -471,32 +497,6 @@ upgraded is calling the retired address and stops earning once the pointer has m Clients MUST NOT send a fund-moving transaction as an automated consequence of an upgrade or a chain event. -## Rationale - -Upgradeable proxies over fund-holding contracts are rejected. A proxy admin can steal -the funds. If deposits live in a contract that cannot be swapped, that event is no -longer a fund-loss event. Bee already compiles addresses into the binary; operators -upgrade Bee, governance flips the pointer at a round boundary, and anyone still on the -old binary stops earning. - -Full-suite redeployment at every breaking Bee release is rejected for the same reason the -split exists. -It requires a batch migration every time, leaves that migration undesigned, and relies -on an incentive that does not hold: operators move stake to keep earning, but a user who -fails to move a batch loses availability they may not notice. After the two one-time -migrations in this SWIP, later upgrades replace policy and `Redistribution` only. - -An immutable policy pointer is stronger and useless: changing policy would mean a new -core, which is another migration. An external timelock is weaker: whoever replaces its -owner shortens the window. Putting expiry ordering in postage policy would make -conservation depend on policy honesty. User-driven `fund()` as the primary batch -migration is rejected because the backing BZZ is locked in `PostageStamp`. - -A policy with no authority over funds cannot slash and cannot pay winners. The -achievable goal is bounded, announced, visible authority with a usable exit. Creation -is policy-gated because admissibility changes per Bee release; exits are not, because a -hostile policy must not trap existing funds. - ## Test cases Mandatory before any core deployment. From 3e1d8e421b733db755a04b833db50063efbbebe5 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Wed, 9 Sep 2026 16:34:26 +0200 Subject: [PATCH 23/24] swip-67: close the staking redistributor gap and tighten the spec Both cores now share a timelocked redistributor pointer; drop RFC voice and leftover jargon. --- SWIPs/swip-67.md | 244 +++++++++++++++++++++++------------------------ 1 file changed, 121 insertions(+), 123 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index 641eb2b6..a8227053 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -55,22 +55,15 @@ Bee keeps shipping contract addresses in the binary, as it does today. The suite is treated contract by contract. -- **`Redistribution`** is not split. A new contract is deployed whenever the *game* - must not be shared — a breaking Bee release (old and new Bee nodes cannot connect, so - they must not share one on-chain game) or a Redistribution code change. Same bytecode - still gets a new address on a breaking Bee release. The redistributor pointer flips at - a round boundary; at most one redistributor is authorised at any block. +- **`Redistribution`** is not split. Redeploy whenever the on-chain game must not be + shared; the pointer flips at a round boundary. - **`StakeRegistry`** splits into `StakingCore` (BZZ, the #309 queue, freeze, payouts) - and `StakingPolicy` (overlay derivation, height / `MIN_STAKE`, eligibility views). The - staking *lifecycle* is - [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) - (queued deposit / top-up / height / overlay / withdraw / exit), not a second unbonding - design. Operators migrate stake once, then deposits stay put across later upgrades. -- **`PostageStamp`** splits into `PostageAccounting` (balances, accumulator, pot, expiry - ordering) and `PostagePolicy` (admissibility, depth rules, price submission). Batches - are seeded once, treasury-matched; after that they carry across later upgrades. -- **`PriceOracle`** is not split. It is redeployed when adjustment rules change, and - submits prices through `PostagePolicy` into the core's bounded `setPrice`. + and `StakingPolicy` (overlay derivation, height / `MIN_STAKE`, eligibility views). + Operators migrate stake once. +- **`PostageStamp`** splits into `PostageAccounting` and `PostagePolicy`. Batches are + seeded once; operators front the matching BZZ and recover it from the stopped contract. +- **`PriceOracle`** is not split. Redeploy when adjustment rules change; it submits + through `PostagePolicy` into the core's bounded `setPrice`. Cores hold all user BZZ. No core function transfers to a caller-supplied address. Pointers change only after a core-enforced timelock. There is no admin pause on `refundBatch` or a @@ -88,9 +81,6 @@ over state — which is a power over funds. We oscillate between two bad options becomes a fund movement for every user and operator, and no batch migration has ever been completed without admin-driven cloning. -That deadlock is a consequence of the coupling, not of the threat model. Break the -coupling and both options improve at once. - **Where the money can move today.** `PostageStamp.withdraw(address beneficiary)` is gated on `REDISTRIBUTOR_ROLE` and sends the whole pot to a caller-supplied address. That role is an OpenZeppelin `AccessControl` @@ -119,11 +109,10 @@ upgrade Bee, governance flips the pointer at a round boundary, and anyone still old binary stops earning. Full-suite redeployment at every breaking Bee release is rejected for the same reason the -split exists. -It requires a batch migration every time, leaves that migration undesigned, and relies -on an incentive that does not hold: operators move stake to keep earning, but a user who -fails to move a batch loses availability they may not notice. After the two one-time -migrations in this SWIP, later upgrades replace policy and `Redistribution` only. +split exists. It requires a batch migration every time, leaves that migration undesigned, +and relies on an incentive that does not hold: operators move stake to keep earning, but +a user who fails to move a batch loses availability they may not notice. After the two +one-time migrations in this SWIP, later upgrades replace policy and `Redistribution` only. An immutable policy pointer is stronger and useless: changing policy would mean a new core, which is another migration. An external timelock is weaker: whoever replaces its @@ -147,15 +136,15 @@ Shared rules for the two cores (`StakingCore`, `PostageAccounting`): - Conservation is checked incrementally on every state-changing call: `pot + sum(remaining claims) <= balance` on postage; `totalDeposited - totalWithdrawn <= balance` on staking (slashed BZZ stays in the contract). -- Calls go policy → core only. The core never reads policy. -- Value-moving primitives policy can trigger are rate-limited by immutable core windows: - `claimPot`, `slash`, `setPrice`. +- Cores never call out. Policy and Redistribution call in. The core never reads policy. +- `claimPot` and `slash` are redistributor-only and rate-limited by immutable core + windows. `setPrice` is rate-limited the same way (oracle through policy). - Policy and redistributor pointers change only after `POLICY_TIMELOCK`, enforced by the core; a pending change is cancellable. - `refundBatch` has no role check, no pause, and ignores policy locks. Staking payout is [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309): a matured `withdraw` / `exit` pays `msg.sender` via `applyUpdates`. Redistribution freeze - can delay that payout (`FrozenWithdrawal`). An admin pause or a hostile policy cannot. + can delay that payout. An admin pause or a hostile policy cannot. A core with a timelocked pointer is not admin-free. The claim is narrower: no privileged operation can move a user's deposit, and every privileged operation is announced in @@ -165,16 +154,16 @@ advance with an exit window. Two clocks, two jobs. Do not mix them. -**`POLICY_TIMELOCK` is on the cores.** `StakingCore` has one pointer: its `StakingPolicy`. -`PostageAccounting` has two: its `PostagePolicy` and its redistributor (`Redistribution`). +**`POLICY_TIMELOCK` is on the cores.** Each core has two pointers: its policy, and its +redistributor (`Redistribution`). Both cores install the same redistributor address. `Redistribution` and `PriceOracle` have neither a pointer nor a timelock; they are replaced by deploying a new contract and, where needed, flipping a pointer *on a core*. +There is no `REDISTRIBUTOR_ROLE` on either core after the split — only the pointer. -The governing multisig is the only address that may propose or cancel a pointer change. +The governing multisig is the only address that can propose or cancel a pointer change. The core enforces the delay itself with an immutable block count. Suggested: `POLICY_TIMELOCK` = 14 days in blocks. The exact value, and the other immutables -(`WAIT_*` from [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309), -`EXECUTION_WINDOW`, slash and pot windows, `MAX_PRICE` / +(`WAIT_*` from #309, `EXECUTION_WINDOW`, slash and pot windows, `MAX_PRICE` / `MAX_PRICE_CHANGE_PER_UPDATE`, `refundBatch` forfeit), are decided before the cores are deployed. No external timelock contract. @@ -184,30 +173,35 @@ full timelock. The date is chosen at **execute** time, once the binary is actual Sequence: -1. **Propose.** Multisig calls `proposePolicy(next)` or `proposeRedistributor(next)`. - The core stores `next` and `proposeBlock`, and emits an event. Nothing has switched - yet. The old `Redistribution` keeps paying. -2. **Wait.** For `POLICY_TIMELOCK` blocks, users who dislike `next` can `exit` or - `refundBatch`. The multisig MAY cancel during this window; it MUST NOT shorten the - delay. If Bee is late, **do nothing** — the old game continues, or pause it and - accept a gap. That gap already happens today. It is not a reason to re-propose. +1. **Propose.** Multisig calls `proposePolicy(next)` or `proposeRedistributor(next)` on + each core that is moving. The core stores `next` and `proposeBlock`, and emits an + event. Nothing has switched yet. The old `Redistribution` keeps paying. +2. **Wait.** For `POLICY_TIMELOCK` blocks, batch owners who dislike `next` can + `refundBatch` and are paid in that window. Stakers can queue `exit` or `withdraw`; + the BZZ still pays out after `WAIT_WITHDRAWAL` on the core, which may be after the + flip. The multisig can cancel during this window; it cannot shorten the delay. If + Bee is late, **do nothing** — the old game continues, or pause it and accept a gap. + That gap already happens today. It is not a reason to re-propose. 3. **Execute**, after the delay, when Bee is out. `executePolicy()` has no extra clock. `executeRedistributor()` also requires the current block to be in the opening of a round: `block.number % ROUND_LENGTH < EXECUTION_WINDOW`. `ROUND_LENGTH` is an - immutable on `PostageAccounting` (152 today). Any later round start is valid; there - is no missed date. A mid-round execute reverts, so committed nodes are not orphaned. - Anyone MAY execute once those checks pass; only the proposed `next` is installed. + immutable on both cores (152 today), the same value. A later Redistribution cannot + carry a different length; changing the clock is a new pair of cores. Any later round + start is valid; there is no missed date. A mid-round execute reverts. Anyone can + execute once those checks pass; only the proposed `next` is installed. Both cores + flip the same redistributor in the same ceremony. Do **not** let the multisig `setRedistributor` in one shot with no propose step. That drops the exit window, which is the whole point of the timelock. Round alignment without a pre-committed block is enough to avoid a mid-round flip; a gap with nobody -playing is acceptable. +playing is acceptable. A flip in the opening window can leave that one in-flight round +incomplete. That is acceptable. Until `PostageAccounting` exists, stage 1 honours the same rule by operational discipline: one `REDISTRIBUTOR_ROLE`, flipped at a round start after Bee is out. +`StakingCore` already uses the timelocked pointer from the moment it is deployed. -**`WAIT_WITHDRAWAL` is not a governance timelock.** It is the unbonding wait in -[storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) +**`WAIT_WITHDRAWAL` is not a governance timelock.** It is the unbonding wait in #309 before a queued `withdraw` or `exit` can pay `msg.sender`. The staker starts it, not the multisig. Freeze from Redistribution can delay that payout further (same as #309), so exit cannot dodge an in-flight penalty. `refundBatch` has no unbonding delay; the forfeit @@ -216,10 +210,12 @@ fraction is the brake. Worked order for a breaking Bee release: 1. Deploy the new `Redistribution` (and new policy contracts if they change). -2. Multisig `proposeRedistributor(newRedistribution)` (and `proposePolicy` if needed). -3. Ship Bee. Operators upgrade during the timelock. If the release slips, wait; the - old pointer stays live (or pause the old game). -4. After the timelock, at the next round start, `executeRedistributor` (and +2. Multisig `proposeRedistributor(newRedistribution)` on both cores (and `proposePolicy` + if needed). +3. Ship Bee close to the end of the timelock. Operators who upgrade before execute sit + out until the pointer is live. If the release slips, wait; the old pointer stays live + (or pause the old game). +4. After the timelock, at the next round start, `executeRedistributor` on both cores (and `executePolicy`). Non-upgraded nodes stop earning. ```solidity @@ -227,8 +223,6 @@ Worked order for a breaking Bee release: function proposePolicy(address next) external; function cancelPolicy() external; function executePolicy() external; - -// PostageAccounting only function proposeRedistributor(address next) external; function cancelRedistributor() external; function executeRedistributor() external; @@ -245,38 +239,34 @@ balances live in postage. Redistribution only *reads* those and *calls* `claimPo **How it is updated.** Redeploy when the incentive game must not be shared. Two triggers: -1. **Breaking Bee release (Type A).** A Bee version whose nodes cannot connect to the - previous version. The p2p network splits in two. Those two networks see different - chunks and reserve commitments, but if they keep calling the **same** `Redistribution` - address they play one on-chain commit / reveal / claim game. The contract cannot tell - the two networks apart. Divergent reveals look like lying: old-version nodes can win - the pot, upgraded nodes get frozen for "disagreeing." A new `Redistribution` address - is what separates the games. The Solidity can be identical — what changed is Bee, not - the contract. Deploy a new copy, point clients and the postage redistributor pointer - at it. -2. **`Redistribution` itself changed (Type A or B).** A bugfix, a new claim check, a - different round length. There is no upgrade path, so that is also a new deployment. - If Bee's p2p protocol is unchanged this is Type B: operators run the Bee that contains - the new address, then the pointer flips. Anyone still on the old binary stops earning. +1. **Breaking Bee release.** A Bee version whose nodes cannot connect to the previous + version. The p2p network splits in two. Those two networks see different chunks and + reserve commitments, but if they keep calling the **same** `Redistribution` address + they play one on-chain commit / reveal / claim game. The contract cannot tell the two + networks apart. Divergent reveals look like lying: old-version nodes can win the pot, + upgraded nodes get frozen for "disagreeing." A new `Redistribution` address is what + separates the games. The Solidity can be identical — what changed is Bee, not the + contract. Deploy a new copy, point clients and both cores' redistributor pointers at it. +2. **`Redistribution` itself changed.** A bugfix, a new claim check. There is no upgrade + path, so that is also a new deployment. A different round length is not this case: + that clock is frozen on the cores. If Bee's p2p protocol is unchanged this is a + contract-only release: operators run the Bee that contains the new address, then the + pointer flips. Anyone still on the old binary stops earning. Do **not** redeploy `Redistribution` for a postage-policy tweak, an oracle adjustment, or a staking-policy change that does not change how commits are built or verified. Those replace the other contracts; the game address stays if the game is the same. -**Pointer flip.** The postage core holds the only redistributor pointer. See -[Pointers and timelocks](#pointers-and-timelocks). +**Pointer flip.** Both cores hold a redistributor pointer and install the same next +address. See [Pointers and timelocks](#pointers-and-timelocks). Incoming `Redistribution` accepts commits once it is the authorised pointer. `claimPot` -reverts for any caller that is not the current pointer. - -If the previous Bee network still needs to pay for data availability, the outgoing -`Redistribution` MAY keep paying at a reduced, decaying rate. That pot MUST be moved -into the outgoing contract **before** the pointer flips. After that it is never the -authorised pointer again. +reverts for any caller that is not the current postage pointer. `slash` and +`freezeDeposit` revert for any caller that is not the current staking pointer. **Migration.** None. There is no user state to move. Operators run the Bee release that -contains the new address. Until `PostageAccounting` exists, singleton authority is -operational (one `REDISTRIBUTOR_ROLE` holder); the type-level guarantee lands with the -postage split. +contains the new address. Until `PostageAccounting` exists, singleton authority on +postage is operational (one `REDISTRIBUTOR_ROLE` holder); staking already has the +pointer. The type-level guarantee on postage lands with the postage split. ### Staking @@ -299,12 +289,12 @@ and trap funds, which is the same hatch #309 still has via `pause` / `migrateSta | Stays in `StakingCore` (frozen, holds BZZ) | Moves to `StakingPolicy` (replaceable) | |---|---| -| Per-account BZZ, the #309 update queue, `WAIT_*`, `freezeUntilBlock`, `applyUpdates` payouts, slash | Overlay derivation (`NetworkId`), height / `MIN_STAKE` rules, effective-stake and lookahead views Redistribution and Bee call | +| Per-account BZZ, the #309 update queue, `WAIT_*`, `freezeUntilBlock`, `applyUpdates` payouts, slash, redistributor pointer | Overlay derivation (`NetworkId`), height / `MIN_STAKE` rules, effective-stake and lookahead views Redistribution and Bee call | -`StakingCore` MUST NOT read `PriceOracle`. Overlay mixes `NetworkId`, so derivation is +`StakingCore` does not read `PriceOracle`. Overlay mixes `NetworkId`, so derivation is policy and is replaced with a breaking Bee release; deposits are not. The core stores the -overlay *bytes* and height as #309 does, so there is still one FIFO. Policy supplies -derivation and the replaceable view API. +overlay *bytes* and height as #309 does, so there is still one queue per account. Policy +supplies derivation and the replaceable view API. ```solidity interface IStakingCore { @@ -320,19 +310,23 @@ interface IStakingCore { function proposePolicy(address next) external; function cancelPolicy() external; function executePolicy() external; + function proposeRedistributor(address next) external; + function cancelRedistributor() external; + function executeRedistributor() external; function depositOf(address account) external view returns (uint256); function totalDeposited() external view returns (uint256); + function redistributor() external view returns (address); } ``` Bee still speaks #309 (`nonce` in, `effectiveFromRound` out). Overlay derivation and `MIN_STAKE` checks live on the policy, which forwards into the core, so the core never -reads policy. `addTokens`, `exit`, and `applyUpdates` are permissionless. `applyUpdates` -pays the owner only, once `WAIT_WITHDRAWAL` has elapsed, unless `FrozenWithdrawal`. -`freezeDeposit` is `onlyRedistributor` and monotonic; it survives exit on this core -(same as #309). `slash` is `onlyRedistributor` and burns in place. `WAIT_WITHDRAWAL` -MUST be at least the longest Redistribution freeze, or a queued exit lands before the -penalty. +reads policy. `addTokens`, `withdraw`, `exit`, and `applyUpdates` are permissionless. +`applyUpdates` pays the owner only, once `WAIT_WITHDRAWAL` has elapsed, unless a +redistributor freeze is still in force. `freezeDeposit` is only the authorised +redistributor and monotonic; it survives exit on this core (same as #309). `slash` is +only the authorised redistributor and burns in place. `WAIT_WITHDRAWAL` is at least the +longest Redistribution freeze, or a queued exit lands before the penalty. No `pause` on the core. #309's `whenNotPaused` on `withdraw` / `exit` is the admin hatch this split removes. @@ -340,13 +334,14 @@ this split removes. **Eligibility.** #309's `WAIT_BASE` after `createDeposit`. No parallel `firstDepositBlock` / pre-registration clock. Overlay change uses `WAIT_OVERLAY_CHANGE` and does not reset the deposit. Height still scales `MIN_STAKE`; the token amount in the -core is deposited BZZ, not a committed/potential pair. +core is deposited BZZ, not today's committed-stake / potential-stake split (committed +was oracle-priced, potential was BZZ). **Accounts and nodes.** Deposits are per account. One account may back several nodes -only if policy admits that; summed usable stake MUST NOT exceed the account's deposit. +only if policy admits that; summed usable stake cannot exceed the account's deposit. A slash reduces the account, and therefore every overlay it backs. -`StakingPolicy` SHOULD take an immutable `predecessor` and lazily inherit overlay and +`StakingPolicy` takes an immutable `predecessor` and lazily inherits overlay and height on first use, so a later upgrade needs no operator transaction. **Migration (once).** Ship or adopt #309 on the current registry first if that lifecycle @@ -357,7 +352,7 @@ the core. After this, stake does not move again: later upgrades only replace `withdraw` / `exit`, or take that one-shot `migrateStake` if the registry is being retired. -**After the split.** Policy-pointer change on `StakingCore` as in +**After the split.** Pointer changes on `StakingCore` as in [Pointers and timelocks](#pointers-and-timelocks). The #309 enqueue / `applyUpdates` ABI is what Bee talks to for deposits and payouts. @@ -373,7 +368,7 @@ Depth and the expiry ordering stay in the core because conservation needs them. accrual is the same identity as today's `expireLimited`: expired batches contribute `batchSize * (normalisedBalance - lastExpiryBalance)`; live chunks contribute `validChunkCount * (currentTotalOutPayment() - lastExpiryBalance)`. Live-chunk accrual -MUST NOT settle while an expired batch is still counted — which is why the core, not +does not settle while an expired batch is still counted — which is why the core, not policy, owns the minimum-balance index. A policy-side accumulator would rebase every batch on every policy replacement. @@ -414,8 +409,10 @@ There is no `withdraw(address)` and no unbacked creation path. `fund` and `resize` are policy-gated (admissibility). A dead policy can block new batches, never `topUp`, `refundBatch`, `expire`, or conservation. New ids derive from -`(originator, nonce)`, same binding as today. Arbitrary ids exist only as genesis-seeded -state; after sealing there is no such path. +`(originator, nonce)`, where `originator` is today's `msg.sender` — not the batch owner. +Arbitrary ids exist only as genesis-seeded state; after sealing there is no such path. +The core rejects a `resize` that increases total remaining claim +(`remainingBalance * 2^depth`). Depth can rise; unbacked claim cannot. `claimPot(amount)` pays the authorised redistributor, not a caller-supplied address, and is capped per window. The cap limits *acceleration*; the honest game already pays the @@ -426,28 +423,28 @@ whole pot each round. Protection against a hostile redistributor is the timelock a forfeit to the pot — the owner can take funds out, just a bit less, to cover leftover pot operation and remaining availability. There is no minimum batch age. The forfeit fraction is an immutable, chosen before deployment. Nodes treat a refund as batch -invalidation, same as expiry. Introducing `refundBatch` is Type A: stamp validity is -consensus-adjacent. +invalidation, same as expiry. Introducing `refundBatch` needs a breaking Bee release: +stamp validity is consensus-adjacent. -**Migration (once), treasury-matched genesis.** `PostageStamp` cannot release unexpired -deposits, so the new core is seeded and separately backed: +**Migration (once).** `PostageStamp` cannot release unexpired deposits, so the new core +is seeded and separately backed: 1. When the new core goes live, `PostageStamp` is paused (`createBatch`, `topUp`, `increaseDepth` freeze; expiry and `withdraw` continue). 2. `PostageAccounting` is deployed in a genesis phase. The deployer seeds the batch set - as of that block. Matching BZZ is topped up on the new core by the operators of the - migration (not a user-by-user movement). + from a snapshot taken at pause. Seeding takes many transactions; expiry still runs on + the old contract, so the seed is that snapshot, not live state. Operators front the + matching BZZ on the new core (not a user-by-user movement). 3. Genesis is sealed in the same ceremony. Until sealed, no other call is accepted; after sealing, no seeding path exists. -4. After the old contract is stopped, remaining BZZ is withdrawn from it - (`withdraw` with the migration operators as beneficiary) — the final announced use of - that primitive. That withdraw, plus the top-up of the new core, is the float. There - is no open reimbursement-tail problem. +4. After the old contract is stopped, operators recover the BZZ they fronted by + withdrawing remaining BZZ from it (`withdraw` with the migration operators as + beneficiary) — the final announced use of that primitive. After this, batches do not migrate again. Later upgrades only replace `PostagePolicy` and `Redistribution`. -**After the split.** Redistributor and policy pointers as in +**After the split.** Pointers as in [Pointers and timelocks](#pointers-and-timelocks). Balance reads never change ABI. @@ -465,12 +462,12 @@ oracle does not. **How it is updated.** Redeployed when adjustment rules change (the rate table, the redundancy target, the pause behaviour). Ship the new address in Bee; flip nothing on -the postage core except through the existing `setPrice` path. Type A only if a -consensus-critical consumer would need a runtime branch. +the postage core except through the existing `setPrice` path. A breaking Bee release +only if a consensus-critical consumer would need a runtime branch. Price submission is `PriceOracle` → `PostagePolicy` → `PostageAccounting.setPrice`. The core enforces `price <= MAX_PRICE` and a maximum step from `lastPrice`. Those bounds -MUST be compatible with the oracle's own steps, or honest adjustments revert. +have to match the oracle's own steps, or honest adjustments revert. **Migration.** None. Operators run the Bee release that contains the new oracle address. In-flight postage balances are unaffected: they are denominated in the core @@ -480,8 +477,9 @@ accumulator, not in the oracle. Bee ships the current addresses and ABIs in the binary, as it does today. Operators switch by running that Bee. Governance proposes pointer changes on the cores, then -executes them after `POLICY_TIMELOCK`. The redistributor pointer may execute only at a -round start, whichever round comes after Bee is out. Details: +executes them after `POLICY_TIMELOCK`. The redistributor pointer executes only at a +round start, whichever round comes after Bee is out. Ship Bee close to that execute; +operators who upgrade early sit out until the pointer is live. Details: [Pointers and timelocks](#pointers-and-timelocks). A **breaking Bee release (Type A)** is a Bee version whose nodes cannot connect to the @@ -491,11 +489,12 @@ commitment hashing, overlay derivation, eligibility, or stamp validity (includin `refundBatch`). A **contract-only release (Type B)** does not change Bee's p2p protocol. Still ship the -new addresses in Bee; still flip the pointer at a round start after the timelock. A node that has not -upgraded is calling the retired address and stops earning once the pointer has moved. +new addresses in Bee; still flip the pointer at a round start after the timelock. A node +that has not upgraded is calling the retired address and stops earning once the pointer +has moved. -Clients MUST NOT send a fund-moving transaction as an automated consequence of an -upgrade or a chain event. +Clients do not send a fund-moving transaction as an automated consequence of an upgrade +or a chain event. ## Test cases @@ -503,20 +502,24 @@ Mandatory before any core deployment. - Conservation after every call, including `resize`, `refundBatch`, expiry and price changes. Differentially check postage pot accrual against today's `expireLimited`. + `resize` that increases total remaining claim reverts. - No transfer to an address not derived from core state (static check on bytecode). - No core call into the policy address. - Malicious policy: flash-drain, unbounded slash, over-claim, unbacked fund, value-inflating resize, blocked exit, over-max price. All revert; a matured staking `applyUpdates` and `refundBatch` still succeed (including policy = 0 and a pending pointer change). - `FrozenWithdrawal` delays a due withdraw/exit until the redistributor freeze ends, then - payout succeeds. + A redistributor freeze delays a due withdraw/exit until the freeze ends, then payout + succeeds. +- `slash` and `freezeDeposit` revert for any caller that is not the staking + redistributor pointer. `claimPot` reverts for any caller that is not the postage + redistributor pointer. There is no role-grant path that bypasses the pointer. - Genesis: seeding without matching BZZ reverts; any call before seal reverts; seeding after seal reverts from every role; conservation holds at the first open block. - Pointer changes cannot execute before `POLICY_TIMELOCK`. Redistributor execute additionally reverts unless the current block is in the opening of a round. Cancellation works only before execution. -- A pointer flip off a round boundary reverts; a boundary-aligned flip does not orphan - a commit. +- A pointer flip off a round boundary reverts. A flip in the opening window can leave + that one in-flight round incomplete. - Policy replacement does not change `currentTotalOutPayment` or remaining balances. - A deposit is eligible after `WAIT_BASE`; overlay change after `WAIT_OVERLAY_CHANGE`; withdraw/exit payout after `WAIT_WITHDRAWAL` and not while frozen. @@ -525,22 +528,17 @@ Mandatory before any core deployment. ## Implementation -Each stage is independently valuable and independently revertible. - | Stage | Content | Depends on | |---|---|---| | 1 | New `Redistribution`; round-aligned pointer flip; one redistributor by operational discipline | — | | 2 | New `Redistribution` on every breaking Bee release, as standing practice | — | -| 3 | Adopt [storage-incentives#309](https://github.com/ethersphere/storage-incentives/pull/309) if not live, then `StakingCore` + `StakingPolicy`. Final stake migration. No pause on the core | 2 | -| 4 | `PostageAccounting` + `PostagePolicy`. Treasury-matched genesis | 3 | +| 3 | Adopt #309 if not live, then `StakingCore` + `StakingPolicy`. Final stake migration. No pause on the core. Timelocked redistributor pointer from this stage | 2 | +| 4 | `PostageAccounting` + `PostagePolicy`. Operators front matching BZZ, then recover it from the stopped contract | 3 | | 5 | Multisig scope reduced to policy and redistributor pointers | 4 | `PriceOracle` has no dedicated stage: redeploy it with the postage or redistribution release that needs the new adjustment rules. -After stage 4, surgical redeployment and the absence of admin power over deposits -coexist. - ## Copyright Copyright and related rights waived via [CC0](https://creativecommons.org/publicdomain/zero/1.0/). From 7468573cd3160d810dbc84b1b973c303037dd896 Mon Sep 17 00:00:00 2001 From: Cardinal Date: Thu, 10 Sep 2026 08:14:27 +0200 Subject: [PATCH 24/24] swip-67: say pot is not remaining balances Answer why the core exists without a proxy or an immutable redistributor. --- SWIPs/swip-67.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/SWIPs/swip-67.md b/SWIPs/swip-67.md index a8227053..e6d7f99c 100644 --- a/SWIPs/swip-67.md +++ b/SWIPs/swip-67.md @@ -108,6 +108,13 @@ longer a fund-loss event. Bee already compiles addresses into the binary; operat upgrade Bee, governance flips the pointer at a round boundary, and anyone still on the old binary stops earning. +The core holds remaining balances, the pot, and conservation. The admin can still +point a new redistributor — that is how Redistribution is replaced without a new +postage contract — but only after `POLICY_TIMELOCK`. Instant assign, or a proxy, +takes the pot with no exit. An immutable redistributor forces a new core every game +change. Batch owners do not authorise the upgrade; they can `refundBatch` in the +window. + Full-suite redeployment at every breaking Bee release is rejected for the same reason the split exists. It requires a batch migration every time, leaves that migration undesigned, and relies on an incentive that does not hold: operators move stake to keep earning, but @@ -448,9 +455,11 @@ After this, batches do not migrate again. Later upgrades only replace `PostagePo [Pointers and timelocks](#pointers-and-timelocks). Balance reads never change ABI. -**Residual trust.** Deposits cannot be stolen or flash-drained. A hostile policy can -still, after the timelock, claim the pot at up to the honest rate and bias who wins. -Custody separation protects deposits, not rewards. +**Residual trust.** The pot is accrued fees, not remaining batch balances. A proxy +can take both, rewrite owners, and mint unbacked batches. After the timelock a +hostile redistributor can take the pot at the capped `claimPot` / `setPrice` rate +(the redistributor feeds the oracle; the core still caps the step). Remaining +leaves through `refundBatch`. Custody separation protects deposits, not rewards. ### PriceOracle