Skip to content

Commit af10ce5

Browse files
authored
feat(reward): model f099 burn sentinel in mock (#29)
1 parent 2223ef7 commit af10ce5

3 files changed

Lines changed: 137 additions & 18 deletions

File tree

docs/f02-design.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ streams[] [ id, WeightRecord[v_start, slope, t_start, floor, cap], distri
5353
# stream only (the block winner)
5454
EXPLICIT = [ writer, # FIP "designated writer": may call SetShares.
5555
# The SRA for the service stream. Not a payee.
56-
shares[]: [[wallet, share], ...] # payees; <= MAX_RECIPIENTS
56+
shares[]: [[wallet, share], ...] # payees; <= MAX_RECIPIENTS.
57+
# Sums to DENOM on the wire; stored shares may sum
58+
# lower, the shortfall being the burn share (see
59+
# "The f099 burn sentinel").
5760
payable[]: [[wallet, amount], ...] # unclaimed from closed periods
5861
claimed_period[]: [[wallet, amount], ...] ] # claimed from the current accrual
5962
@@ -131,7 +134,11 @@ AwardBlockReward(miner, penalty, gas_reward, win_count):
131134
if any weight record is malformed or sum(evaluated weights) > DENOM:
132135
pay gas_reward and apply the penalty; commit no state or counter change; return
133136
miner = floor(w1 * BR)
134-
explicit = floor(w2 * BR) if accounting_valid else 0
137+
for each explicit stream:
138+
portion = floor(w_stream * BR)
139+
# burn sentinel: stored shares summing below DENOM burn the shortfall
140+
accrue_to_stream = floor(portion * stored_share_total / DENOM)
141+
explicit = sum(accrue_to_stream) if accounting_valid else 0
135142
burn = BR - miner - explicit
136143
total_minted_reward += BR
137144
total_burn_minted += burn
@@ -159,13 +166,14 @@ Applied and dropped events on this implicit path are best-effort. FIP-0107 is re
159166
```
160167
SetShares(stream_id, new_map):
161168
caller must be the stream's designated writer
162-
validate sum(new_map shares) == 1 and every share > 0
169+
validate sum(new_map shares) == DENOM and every share > 0
163170
resolve recipients to ID addresses; reject the call if any does not resolve
164171
(recipients must exist; the SRA pre-validates at registration, so this is
165172
a backstop against typos and stranded credits)
173+
strip any f099 rows before storing; their weight becomes the burn share
166174
pool = accrued[stream_id]
167175
for each (wallet, share) in the OLD map:
168-
earned = floor(share * pool)
176+
earned = floor(share * pool / stored_share_total)
169177
payable[wallet] += earned - claimed_period[wallet]
170178
residue = pool - sum(earned) # rounding dust only
171179
send(f099, residue) # neither counter moves
@@ -190,7 +198,8 @@ Claim(stream_id, wallets[]) -> amounts[]:
190198
entitlement = its payable[wallet] # nothing live on a tombstone
191199
else:
192200
s = the stream's EXPLICIT distribution
193-
live = floor(share_of(s.shares, wallet) * accrued[stream_id])
201+
live = floor(share_of(s.shares, wallet) * accrued[stream_id]
202+
/ stored_share_total(s.shares)) # zero total pays nothing
194203
- amount_of(s.claimed_period, wallet) # current period
195204
entitlement = live + amount_of(s.payable, wallet) # + unclaimed previous
196205
if entitlement == 0: amounts[i] = 0; continue
@@ -204,6 +213,28 @@ Claim(stream_id, wallets[]) -> amounts[]:
204213

205214
Permissionless, batched, recipients fixed by the map, works mid-period. A batch is capped at `MAX_RECIPIENTS`, so a full payable table drains in two calls. Zero-entitlement entries (unknown wallets, duplicates within the batch) pay nothing and return 0 at their position. An unknown or deleted stream id returns all zeros, so a repeated claim after its tombstone drains is benign. An all-zero batch succeeds.
206215

216+
## The f099 burn sentinel
217+
218+
f099 in an explicit share map is a burn instruction, not a payee. Wire shares still sum to
219+
`DENOM`; share-map admission validates that checksum, then strips all f099 rows. The stored
220+
shortfall is the burn share. Persisted state rejects f099, so it cannot accrue, be claimed, or
221+
reach a tombstone.
222+
223+
For each explicit stream award:
224+
225+
```
226+
accrue = floor(portion * stored_share_total / DENOM)
227+
burn += portion - accrue
228+
```
229+
230+
Only `accrue` enters the stream pool and `total_explicit_minted`; the remainder uses the
231+
existing burn send and counter. Flooring the survivor side prevents a removal from improving
232+
survivor earnings through rounding.
233+
234+
Claim and fold divide the survivor pool by `stored_share_total`, leaving stored share values
235+
unchanged. A total of `DENOM` uses the ordinary path; zero accrues nothing and burns the whole
236+
portion. The total is derived from the inline share vector, bounded by `MAX_RECIPIENTS`.
237+
207238
## Supply accounting
208239

209240
Circulating supply is consensus-relevant (`GetFilMined` feeds initial pledge) and Lotus, Forest, and Venus each compute it independently, so its inputs do 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.

test/mocks/FVMRewardActor.sol

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {Vm} from "forge-std/Vm.sol";
66
import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol";
77
import {CBOR_CODEC} from "fvm-solidity/FVMCodec.sol";
88
import {FVMPay} from "fvm-solidity/FVMPay.sol";
9+
import {BURN_ADDRESS} from "fvm-solidity/FVMActors.sol";
910

1011
import {
1112
SET_WEIGHT_RECORDS,
@@ -221,8 +222,10 @@ contract FVMRewardActor {
221222
if (s.kind == DistributionKind.IMPLICIT) {
222223
minerPortion += amount;
223224
} else {
224-
servicePortion += amount;
225-
s.accrued += amount;
225+
uint256 storedShareTotal = _storedShareTotal(s);
226+
uint256 accrued = storedShareTotal == SHARE_TOTAL ? amount : (amount * storedShareTotal) / SHARE_TOTAL;
227+
servicePortion += accrued;
228+
s.accrued += accrued;
226229
}
227230
}
228231
burnAmount = br - minerPortion - servicePortion;
@@ -347,9 +350,7 @@ contract FVMRewardActor {
347350
_foldAndBurnResidue(s);
348351

349352
delete s.shares;
350-
for (uint256 i = 0; i < newShares.length; i++) {
351-
s.shares.push(newShares[i]);
352-
}
353+
_pushNonBurnShares(s.shares, newShares);
353354
return (0, 0, "");
354355
}
355356

@@ -469,9 +470,7 @@ contract FVMRewardActor {
469470
if (!_admits(proposed)) return (USR_ILLEGAL_ARGUMENT, 0, "");
470471

471472
delete _pendingShares[id];
472-
for (uint256 i = 0; i < shares.length; i++) {
473-
_pendingShares[id].push(shares[i]);
474-
}
473+
_pushNonBurnShares(_pendingShares[id], shares);
475474
_queueWrite(
476475
id,
477476
PendingOp.REGISTER,
@@ -600,6 +599,9 @@ contract FVMRewardActor {
600599
if (s.kind != DistributionKind.EXPLICIT) return (USR_ILLEGAL_ARGUMENT, 0, "");
601600
}
602601

602+
uint256 storedShareTotal;
603+
if (!tombstoned) storedShareTotal = _storedShareTotal(s);
604+
603605
uint256[] memory amounts = new uint256[](wallets.length);
604606
for (uint256 i = 0; i < wallets.length; i++) {
605607
address wallet = wallets[i];
@@ -615,7 +617,7 @@ contract FVMRewardActor {
615617
} else {
616618
uint256 share = _shareOf(s, wallet);
617619
uint256 claimed = s.claimedPeriod.amount[wallet];
618-
uint256 grossLive = (share * s.accrued) / SHARE_TOTAL;
620+
uint256 grossLive = storedShareTotal == 0 ? 0 : (share * s.accrued) / storedShareTotal;
619621
uint256 live = grossLive > claimed ? grossLive - claimed : 0;
620622
uint256 payableAmount = s.payableLedger.amount[wallet];
621623
entitlement = live + payableAmount;
@@ -983,14 +985,16 @@ contract FVMRewardActor {
983985
/// @dev validate_weight_record: floor <= v_start <= cap <= DENOM. The lower bound on floor
984986
/// is implicit in f02, where these three are u64; here they are signed and it is not.
985987
/// @dev validate_shares: at most MAX_RECIPIENTS rows, every share nonzero, no repeated
986-
/// recipient, and the whole map summing to one.
988+
/// payee, and the whole wire map summing to one. Repeated burn instructions are equivalent.
987989
function _sharesValid(Share[] memory shares) internal pure returns (bool) {
988990
if (shares.length > MAX_RECIPIENTS) return false;
989991
uint256 total;
990992
for (uint256 i = 0; i < shares.length; i++) {
991993
if (shares[i].share == 0) return false;
992-
for (uint256 j = 0; j < i; j++) {
993-
if (shares[j].wallet == shares[i].wallet) return false;
994+
if (shares[i].wallet != BURN_ADDRESS) {
995+
for (uint256 j = 0; j < i; j++) {
996+
if (shares[j].wallet == shares[i].wallet) return false;
997+
}
994998
}
995999
total += shares[i].share;
9961000
}
@@ -1254,14 +1258,27 @@ contract FVMRewardActor {
12541258
return 0;
12551259
}
12561260

1261+
function _storedShareTotal(Stream storage s) internal view returns (uint256 total) {
1262+
for (uint256 i = 0; i < s.shares.length; i++) {
1263+
total += s.shares[i].share;
1264+
}
1265+
}
1266+
1267+
function _pushNonBurnShares(Share[] storage target, Share[] memory shares) internal {
1268+
for (uint256 i = 0; i < shares.length; i++) {
1269+
if (shares[i].wallet != BURN_ADDRESS) target.push(shares[i]);
1270+
}
1271+
}
1272+
12571273
/// @dev Closes out the current period: each recipient's earned-minus-claimed amount moves
12581274
/// into `payable` under the OLD map, the rounding residue burns, and accrual state resets.
12591275
function _foldAndBurnResidue(Stream storage s) internal {
12601276
uint256 pool = s.accrued;
1277+
uint256 shareTotal = _storedShareTotal(s);
12611278
uint256 earnedSum;
12621279
for (uint256 i = 0; i < s.shares.length; i++) {
12631280
address wallet = s.shares[i].wallet;
1264-
uint256 earned = (s.shares[i].share * pool) / SHARE_TOTAL;
1281+
uint256 earned = (s.shares[i].share * pool) / shareTotal;
12651282
earnedSum += earned;
12661283
uint256 claimed = s.claimedPeriod.amount[wallet];
12671284
if (earned > claimed) {

test/mocks/FVMRewardActor.t.sol

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,36 @@ contract FVMRewardActorTest is MockRewardTest {
348348
assertEq(exitCode, USR_ILLEGAL_ARGUMENT);
349349
}
350350

351+
function test_RegisterStream_StripsBurnSentinels_AndFloorsSurvivorPool() public {
352+
uint256 third = SHARE_TOTAL / 3;
353+
Share[] memory shares_ = new Share[](4);
354+
shares_[0] = Share({wallet: RECIPIENT_A, share: third});
355+
shares_[1] = Share({wallet: BURN_ADDRESS, share: third});
356+
shares_[2] = Share({wallet: RECIPIENT_B, share: third});
357+
shares_[3] = Share({wallet: BURN_ADDRESS, share: SHARE_TOTAL - third * 3});
358+
359+
uint32 exitCode = swaCaller.registerStream(
360+
SERVICE_ID, _constantRecord(WAD), address(writerCaller), shares_, uint64(block.number) + SWA_TIMELOCK
361+
);
362+
assertEq(exitCode, 0);
363+
_warpPastTimelockAndSettle();
364+
365+
Share[] memory stored = rewardActor().getShares(SERVICE_ID);
366+
assertEq(stored.length, 2);
367+
assertEq(stored[0].wallet, RECIPIENT_A);
368+
assertEq(stored[0].share, third);
369+
assertEq(stored[1].wallet, RECIPIENT_B);
370+
assertEq(stored[1].share, third);
371+
372+
(uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(2);
373+
assertEq(minerPortion, 0);
374+
assertEq(servicePortion, 1, "floor the survivor pool before deriving burn");
375+
assertEq(burnAmount, 1);
376+
assertEq(_streams()[0].accrued, 1);
377+
assertEq(rewardActor().totalServiceMinted(), 1);
378+
assertEq(rewardActor().totalBurnMinted(), 1);
379+
}
380+
351381
// Zero is reserved and could come to signify the burn stream.
352382
function test_RegisterStream_ZeroId_IllegalArgument() public {
353383
assertEq(
@@ -621,6 +651,47 @@ contract FVMRewardActorTest is MockRewardTest {
621651
assertEq(got[0].wallet, RECIPIENT_B);
622652
}
623653

654+
function test_SetShares_BurnSentinelCarvesAccrual_AndNeverBecomesClaimable() public {
655+
_registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18
656+
657+
Share[] memory partialBurn = new Share[](3);
658+
partialBurn[0] = Share({wallet: RECIPIENT_A, share: SHARE_TOTAL / 4});
659+
partialBurn[1] = Share({wallet: BURN_ADDRESS, share: SHARE_TOTAL / 2});
660+
partialBurn[2] = Share({wallet: RECIPIENT_B, share: SHARE_TOTAL / 4});
661+
assertEq(writerCaller.setShares(SERVICE_ID, partialBurn), 0);
662+
663+
Share[] memory stored = rewardActor().getShares(SERVICE_ID);
664+
assertEq(stored.length, 2);
665+
assertEq(stored[0].wallet, RECIPIENT_A);
666+
assertEq(stored[1].wallet, RECIPIENT_B);
667+
668+
(, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(1 ether);
669+
assertEq(servicePortion, 0.05 ether);
670+
assertEq(burnAmount, 0.95 ether);
671+
assertEq(_streams()[0].accrued, 0.05 ether);
672+
673+
(, uint256[] memory firstClaim) = _claim(SERVICE_ID, _wallets(RECIPIENT_A));
674+
assertEq(firstClaim[0], 0.025 ether, "live claims divide by the stored share total");
675+
676+
assertEq(writerCaller.setShares(SERVICE_ID, _shares(BURN_ADDRESS, SHARE_TOTAL)), 0);
677+
assertEq(rewardActor().getShares(SERVICE_ID).length, 0);
678+
LedgerRow[] memory payableRows = rewardActor().getPayable(SERVICE_ID);
679+
assertEq(payableRows.length, 1);
680+
assertEq(payableRows[0].wallet, RECIPIENT_B);
681+
assertEq(payableRows[0].amount, 0.025 ether, "folds divide by the closing stored share total");
682+
assertEq(BURN_ADDRESS.balance, 0.95 ether, "the exact survivor pool leaves no fold residue");
683+
684+
(, uint256[] memory finalClaims) = _claim(SERVICE_ID, _wallets(RECIPIENT_B, BURN_ADDRESS));
685+
assertEq(finalClaims[0], 0.025 ether);
686+
assertEq(finalClaims[1], 0, "the burn sentinel never has a claimable balance");
687+
688+
(, servicePortion, burnAmount) = rewardActor().mockAwardBlockReward(1 ether);
689+
assertEq(servicePortion, 0);
690+
assertEq(burnAmount, 1 ether);
691+
assertEq(rewardActor().totalServiceMinted(), 0.05 ether);
692+
assertEq(rewardActor().totalBurnMinted(), 1.95 ether);
693+
}
694+
624695
function test_RemoveStream_NotSwa_Forbidden() public {
625696
_registerExplicit(SERVICE_ID, address(writerCaller));
626697
uint32 exitCode = randomCaller.removeStream(SERVICE_ID);

0 commit comments

Comments
 (0)