Skip to content

Commit a628fba

Browse files
committed
docs: add initial f02 design proposal
1 parent bb47366 commit a628fba

1 file changed

Lines changed: 203 additions & 0 deletions

File tree

docs/f02-design.md

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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

Comments
 (0)