|
| 1 | +# f02 reward-actor design for Solstice (FIP-0118) |
| 2 | + |
| 3 | +This doc is a design proposal for the Solstice f02 implementation, how it works, and why. It assumes the [FIP-0118 text](https://github.com/filecoin-project/FIPs/pull/1270); SWA, SRA, orchestrator, and the w0/w1/w2 weight names are as defined there. In this design f02 becomes the stream engine: each block it evaluates the weight records, pays the miner, accrues the service stream's portion, and burns the exact residual; SWA governance writes queue inside f02 under the timelock and apply when due. Headlines: settlement is pull, not push, replacing the FIP's per-epoch payouts with a permissionless `Claim`, where funds go to the wallets the share map names; storage position 9 keeps the grand minted total, counted at accrual, so `FilMined` and every client's circulating-supply logic is untouched and the per-stream decomposition is derived; f02 is quarter-agnostic (periods, gates, and cadence are contract-side concerns); per-block-hot values stay inline as bare integers and everything else is offboarded. |
| 4 | + |
| 5 | +## Settlement: pull, not push |
| 6 | + |
| 7 | +The FIP has f02 pushing the service stream out to orchestrator wallets each epoch. Instead this document proposes the opposite: f02 accrues each stream's portion and recipients claim it with an explicit message. Value only leaves f02 inside a `Claim`. |
| 8 | + |
| 9 | +Three reasons: |
| 10 | +* Churn: f02's state block is rewritten every block, so every byte there is re-stored in every snapshot and archival node for the life of the chain; push puts `N` recipient-state writes on that path forever, accrual is one integer increment in a block that is already churning. |
| 11 | +* Observability: the accounting pull needs for entitlements doubles as the ledger, so per-stream accrual and per-wallet entitlements are a state read at any epoch and every payout is an ordinary message with the transfer in its receipt, where push buries transfers in implicit executions that need a replay to see (balance-diffing doesn't recover them: no attribution, no per-epoch resolution). |
| 12 | +* Failure modes: no consensus-path sends to gas-bound, and the FIP's failed-send-burns-your-share rule disappears; a failed `Claim` reverts and the entitlement stays put. |
| 13 | + |
| 14 | +The cost is that someone must send a message and pay gas to get paid. `Claim` is proposed as **permissionless**, and funds can only go to the wallet the share map names, so a keeper settles on a recipient's behalf and money "just arrives" anyway. |
| 15 | + |
| 16 | +## State |
| 17 | + |
| 18 | +Today's f02 state is an 11-field CBOR tuple, rewritten every block. The rule for the changes below: per-block-mutating values are bare integers inline; everything else lives behind one CID, written only at governance, quarterly, or claim cadence. |
| 19 | + |
| 20 | +``` |
| 21 | +Pos Field Change |
| 22 | + 9 total_minted_reward Renamed from total_storage_power_reward, position kept. |
| 23 | + Accrues the full block reward, all streams (was |
| 24 | + miners-only in FIP). FilMined reads it unchanged. |
| 25 | +10,11 simple_total, Deleted, retired to code constants (code comment |
| 26 | + baseline_total suggests this "in a subsequent upgrade"). |
| 27 | +
|
| 28 | +new total_burn_minted Cumulative burn (w0 residual + fold dust). |
| 29 | +new service_accrued[id] Per explicit stream: current period's gross accrual. |
| 30 | +new next_transition_epoch Min effective_epoch over pending_writes; recomputed on |
| 31 | + queue/apply/cancel; sentinel when empty. The per-block |
| 32 | + path compares this one int and doesn't touch the CID. |
| 33 | +new swa_timelock_epochs Per-network hold (7 days mainnet, short on calibnet); |
| 34 | + migration-set only, FIP-0081 ramp-params pattern. |
| 35 | +new streams_root (CID) Everything below. |
| 36 | +``` |
| 37 | + |
| 38 | +Net roughly +47B on the 165B current f02 state root block. |
| 39 | + |
| 40 | +Serialisation is the Filecoin norm of only tuple representation, so "keyed by `(stream_id, wallet)`" is logical only: lookups scan the streams array matching on the stored `id`, and each explicit stream owns its own recipient arrays, so streams can't collide on a shared wallet and a claim rewrites only its own stream's rows. `id`s are SWA-supplied at `RegisterStream` and opaque to f02, which enforces uniqueness across `streams[]`, `tombstones[]`, and pending `RegisterStream` writes, checked at queue time. The SWA keeps the `id`-to-purpose mapping, where `id`s have meaning. Migration pins consensus = 1 and service = 2, matching the w1/w2 subscripts; 0 is reserved (in case we make burn an identified stream later). |
| 41 | + |
| 42 | +The `streams_root` CID links to a block with the following structure: |
| 43 | + |
| 44 | +``` |
| 45 | +streams[] [ id, WeightRecord[v_start, slope, t_start, floor, cap], distribution ] |
| 46 | + # where `distribution` is a two-variant union (FIP's Distribution), encoded as an |
| 47 | + # Option since the IMPLICIT case carries no data: |
| 48 | + IMPLICIT = null # recipient resolved from protocol state; consensus |
| 49 | + # stream only (the block winner) |
| 50 | + EXPLICIT = [ writer, # FIP "designated writer": may call SetShares. |
| 51 | + # The SRA for the service stream. Not a payee. |
| 52 | + shares[]: [[wallet, share], ...] # payees; <= MAX_RECIPIENTS |
| 53 | + payable[]: [[wallet, amount], ...] # unclaimed from closed (previous) periods |
| 54 | + claimed_period[]: [[wallet, amount], ...] # claims this period; subtract Σ from |
| 55 | + # service_accrued for what's still owed |
| 56 | + # (see supply accounting) |
| 57 | + claimed_total ] # lifetime; survives removal |
| 58 | +
|
| 59 | +tombstones[] [ [id, claimed_total, payable[]], ... ] |
| 60 | + # removed streams. claimed_total is kept forever (permanent term in |
| 61 | + # derived S and M; see supply). payable[] carries the removed |
| 62 | + # stream's outstanding liabilities so claims stay addressable after |
| 63 | + # removal; rows delete as they're claimed. |
| 64 | +pending_writes[] [ id, op, payload, effective_epoch, origin(mechanism|discretionary) ] |
| 65 | + # a deferred-call list: op+payload capture an SWA method call |
| 66 | + # (SetWeightRecords etc.) w/ args to be executed at effective_epoch |
| 67 | + # = queue epoch + swa_timelock_epochs (RegisterStream may schedule |
| 68 | + # later; the hold is a floor). |
| 69 | + # SetShares never queues. origin exists only for CancelPending: |
| 70 | + # mechanism (gate) writes can't be cancelled, discretionary |
| 71 | + # (governance) can; the tag is caller-asserted (f02 doesn't know). |
| 72 | +``` |
| 73 | + |
| 74 | +Mutation cadence, per method: |
| 75 | + |
| 76 | +| Method | streams block | inline tuple | |
| 77 | +|---|---|---| |
| 78 | +| `AwardBlockReward` (per block) | read (weight records); write only when applying a due queued write | `total_minted`, `total_burn`, `service_accrued`; `next_transition` on apply | |
| 79 | +| `SetWeightRecords` / `RegisterStream` / `RemoveStream` / `SetDistribution` | write: queue entry (second write later, at application) | `next_transition_epoch` | |
| 80 | +| `CancelPending` | write: queue entry removed | `next_transition_epoch` | |
| 81 | +| `SetShares` | write: `shares` / `payable` / `claimed_period` fold | `service_accrued` reset; `total_burn` (residue) | |
| 82 | +| `Claim` | write: claimed rows, `payable`, `claimed_total` | none | |
| 83 | +| `GetState` | read; projects due writes in memory, persists nothing | none | |
| 84 | + |
| 85 | +The award path reads the streams block every block (the records live there). `GetState` projecting rather than persisting keeps reads read-only; the next mutating call or award does the real application. |
| 86 | + |
| 87 | +**Block size.** The streams block is one inline block: any write re-serialises all of it, and the award reads all of it per block, so size costs both. Growth is streams × recipients plus tails (payable, tombstones) that are transient in practice (see state bounds below). Approximate CBOR sizes with ID-normalised addresses (row ~20B, WeightRecord ~40B, ~70B fixed per stream): |
| 88 | + |
| 89 | +``` |
| 90 | +size ≈ 100 + S·(70 + 20·(2N + P)) # S streams, N recipients, P payable rows; queue/tombstones extra, same row arithmetic |
| 91 | +S=1, N=16: ~1.1 KB (v1: fine) S=8, N=32: ~16 KB (heavy) S=100, N=1000: ~6 MB (not ok!) |
| 92 | +``` |
| 93 | + |
| 94 | +`MAX_STREAMS` and `MAX_RECIPIENTS` are named but never valued in the FIP. We should set both low (single digits and low tens) and have the FIP assert that raising them requires a future FIP and network upgrade that also reshapes the affected structures for the new scale (using appropriate expandable data structures). Tombstones likely don't need a cap: growth is gated on `RemoveStream`, a governance-cadence op, and a drained tombstone is ~15B. |
| 95 | + |
| 96 | +We avoid storing floating point values directly on chain, instead using various mechanisms to derive fractional values. The fractions in this FIP (the weight fields and shares) are represented as `u64` fixed point, `DENOM = 1e18`. `1e18` over a power of two because the FIP percentages are exact integers in it, and `Σ shares == DENOM` becomes an exact integer check; the `w * BR` multiply promotes to bignum then `div_floor`s. Slope quantisation over the 9-quarter ramp is ~1e-10pp at this scale and the clamp endpoints are stored exact values, so the ramp rests at exactly 50% regardless *(nicer alternative would be to store the ramp by endpoints instead of a slope)*. |
| 97 | + |
| 98 | +## Award path (per block, implicit) |
| 99 | + |
| 100 | +``` |
| 101 | +AwardBlockReward(miner, penalty, gas_reward, win_count): |
| 102 | + if epoch >= next_transition_epoch: apply due queued writes |
| 103 | + evaluate w1, w2 from the weight records at this epoch (clamped linear) |
| 104 | + BR = this_epoch_reward * win_count / EXPECTED_LEADERS_PER_EPOCH # /5 |
| 105 | + miner = floor(w1 * BR) |
| 106 | + service = floor(w2 * BR) # shares sum to 1, all of w2 accrues |
| 107 | + burn = BR - miner - service # exact residual = w0 * BR |
| 108 | + total_minted_reward += BR |
| 109 | + send(f099, burn); total_burn_minted += burn |
| 110 | + service_accrued += service |
| 111 | + miner receives miner + gas_reward via ApplyRewards |
| 112 | +``` |
| 113 | + |
| 114 | +Burn is the exact residual, so conservation holds to the atto; independent floors of each weight would not conserve, and per-block is not equivalent to per-epoch division under integer rounding (the FIP says it is). Gas stays wholly with the miner and penalties are unchanged. Nothing on this path can fail: the only send is to f099. The one non-trivial step is applying due queued writes. |
| 115 | + |
| 116 | +## SetShares (designated writer, at period boundaries) |
| 117 | + |
| 118 | +``` |
| 119 | +SetShares(stream_id, new_map): |
| 120 | + caller must be the stream's designated writer |
| 121 | + validate sum(new_map shares) == 1; normalise recipients to ID addresses |
| 122 | + pool = service_accrued[stream_id] |
| 123 | + for each (wallet, share) in the OLD map: |
| 124 | + earned = floor(share * pool) |
| 125 | + payable[wallet] += earned - claimed_period[wallet] |
| 126 | + residue = pool - sum(earned) # rounding dust only |
| 127 | + send(f099, residue); total_burn_minted += residue |
| 128 | + service_accrued[stream_id] = 0; clear claimed_period |
| 129 | + install new_map |
| 130 | +``` |
| 131 | + |
| 132 | +Folding in this way wraps up the closing period. Each old recipient's `earned`-minus-`claimed` moves into `payable`, then the period resets. A wallet dropped from the new map keeps its `payable` until it claims. The residue is rounding dust only because shares sum to exactly 1. |
| 133 | + |
| 134 | +A "period" in f02 is just the interval between `SetShares` calls: f02 knows no quarters, `SetShares` binds immediately whenever the writer sends it, and the quarterly cadence is upstream SRA discipline (`SubmitShares` runs once per quarter, post-verification-window, and submits only what SplitRule computes). The fold is what makes immediate binding safe: earnings materialise under the old shares before the new map applies, so a share change is strictly prospective. |
| 135 | + |
| 136 | +## Claim (explicit message) |
| 137 | + |
| 138 | +``` |
| 139 | +Claim(stream_id, wallets[]) -> amounts[]: |
| 140 | + for wallet in wallets: |
| 141 | + if stream_id is a tombstone: |
| 142 | + entitlement = its payable[wallet] # nothing live on a tombstone |
| 143 | + else: |
| 144 | + s = the stream's EXPLICIT distribution |
| 145 | + live = floor(share_of(s.shares, wallet) * service_accrued[stream_id]) |
| 146 | + - amount_of(s.claimed_period, wallet) # current period |
| 147 | + entitlement = live + amount_of(s.payable, wallet) # + unclaimed previous |
| 148 | + if entitlement == 0: amounts[i] = 0; continue |
| 149 | + bump claimed_period[wallet] by live (live case); drop payable[wallet] row |
| 150 | + claimed_total += entitlement |
| 151 | + send(wallet, entitlement) # method 0; cannot fail here |
| 152 | + emit event(stream_id, wallet, entitlement) |
| 153 | + amounts[i] = entitlement |
| 154 | + return amounts |
| 155 | +``` |
| 156 | + |
| 157 | +Permissionless, batched, recipients fixed by the map, works mid-period. Zero-entitlement entries (unknown wallets, duplicates within the batch) pay nothing and return 0 at their position. An all-zero batch is a benign no-op success, not an error: nothing was written, there's nothing to revert, and keepers get idempotent re-runs for free. |
| 158 | + |
| 159 | +## Supply accounting |
| 160 | + |
| 161 | +Circulating supply is consensus-relevant (`GetFilMined` feeds initial pledge) and Lotus, Forest, and Venus each compute it independently, so its inputs would ideally not change. `FilMined` stays "read position 9 of f02": the split changes who receives issuance, not how much, so the field keeps meaning the total and no implementation changes its supply logic. The current FIP 2.5 (position 9 stays miners-only, `FilMined` becomes a sum of new fields) instead forces a lockstep change across three codebases; flipping it makes the correct behaviour the default. |
| 162 | + |
| 163 | +Renaming the 9th field of f02 and keeping it as total minted is correct for `GetFilMined` in the circulating supply calculation but may be breaking for other uses that assume it refers to minted rewards transferred to miners. Code audits should check for such cases. |
| 164 | + |
| 165 | +f02's own supply accounting becomes `T = M + B + S`. |
| 166 | + |
| 167 | +* `T` is position 9. |
| 168 | +* `B` (`total_burn_minted`) is newly stored because w0's f099 contribution is otherwise difficult to recover independently |
| 169 | +* `S` derives from claim state: `S = Σ over streams of (service_accrued - Σ claimed_period + Σ payable + claimed_total)`, tombstones included |
| 170 | +* `M`, what miners have received, is the derived residual `T - B - S`; it is not stored. |
| 171 | + |
| 172 | +## Quarter-agnostic f02, and the schedule |
| 173 | + |
| 174 | +f02 holds no `EPOCHS_PER_QUARTER` and no quarter logic; quarters, windows, and gate cadence are contract-side, and f02's only timing concept is the pending-write hold. Quarter length reaches f02 only through record slopes, so calibnet compression is migration values plus contract params, no code change. |
| 175 | + |
| 176 | +## Methods |
| 177 | + |
| 178 | +All FRC-0042 exported, since the callers are contracts (a `method_hash!` variant plus `actor_dispatch!` arm each): `SetWeightRecords`, `RegisterStream`, `RemoveStream`, `SetDistribution` (SWA-only, queued under the timelock); `SetShares` (designated writer, not queued); `CancelPending` (SWA-relayed, rejects mechanism-origin writes); `GetState` (read; includes `pending_writes`); `Claim` (permissionless, batched). The existing methods keep their numbers and signatures; only `AwardBlockReward`'s internals change. |
| 179 | + |
| 180 | +The timelock is enforced in f02: SWA writes queue with an effective epoch and apply after the hold. The duration is per-network (`swa_timelock_epochs` in state, migration-set only, FIP-0081 ramp-params style; mainnet 7 days, calibnet short). |
| 181 | + |
| 182 | +`GetState` returns the full state, queue included; it's a state dump. Gate-position correctness is the SWA's job: it must keep its own step counter rather than deriving position from f02's w2, which is stale while a write is queued (a check inside the hold re-fires a step it already fired) and, because w2's discrete steps bump up against its ceiling's continuous slope (`1 - w1`), the FIP's `(w2 - W2_INITIAL)/W2_STEP` stops returning an integer as w2 wanders off the 5% grid, so it doesn't provide the necessary stability. |
| 183 | + |
| 184 | +## Lifecycle, queue, validation |
| 185 | + |
| 186 | +Removing a stream or re-pointing its writer first settles the current period, exactly as `SetShares` does: each recipient's earned-but-unclaimed amount is banked as `payable` under the outgoing shares. On removal those debts move to the stream's tombstone along with `claimed_total`; claims against the removed id pay from the tombstone, and `claimed_total` stays forever (derived S and M depend on it). `SetDistribution` changes only the writer; the share map stands until the new writer replaces it, and converting to IMPLICIT is not allowed. |
| 187 | + |
| 188 | +Due writes apply in effective-epoch order (id as tiebreak) at the start of the award and of every mutating method, so no message ever executes against stale config; a later write to the same target simply overwrites the earlier. A small queue cap keeps the apply step trivially bounded. |
| 189 | + |
| 190 | +Schedule validation happens at queue time: a new write is validated against the state **as it will be when it applies**, i.e. with the already-queued calls executed, because two writes each fine against today's records can sum past 1 together (w1:=70 and w2:=35 each pass against a current 60/20; jointly they're 105). Reject the write; the runtime sum check becomes an assertion. This replaces the FIP's underspecified last-valid-values/vector fallback and part of its SWA-side validation split. |
| 191 | + |
| 192 | +State bounds: `MAX_RECIPIENTS` caps a share map but not history, and `Claim` deletes zeroed rows. In normal operation the history terms sit near empty: `payable` fills at the settle and drains as recipients claim, `claimed_period` resets each period, and any `payable` row (live or tombstoned) is flushable by anyone via the permissionless `Claim`. These structures exist for the edge cases (dropped wallets, an absent writer, removed streams), not the norm; a future iteration with _many_ streams may insert one or more HAMTs to contain them, currently considered overkill. |
| 193 | + |
| 194 | +## Migration |
| 195 | + |
| 196 | +The nv29 migration bootstraps two streams as clamped-linear records with `t_start` = activation: |
| 197 | + |
| 198 | +``` |
| 199 | +w1 (consensus, id 1, IMPLICIT): { v_start 0.95, slope -0.45/(9*EPOCHS_PER_QUARTER), floor 0.50, cap 0.95 } |
| 200 | +w2 (service, id 2, EXPLICIT): { v_start 0.05, slope +0.45/(9*EPOCHS_PER_QUARTER), floor 0.05, cap 0.10 } |
| 201 | +``` |
| 202 | + |
| 203 | +The slopes cancel over Q1 so nothing burns during bootstrap, and w2 hits its cap exactly at the Q1 boundary, auto-exiting the bootstrap ramp with no scheduled write needed. The service stream starts with the single-orchestrator share map (one wallet, share = 1) and the SRA as designated writer. `swa_timelock_epochs` is set per network here (the only place it's ever written). |
0 commit comments