Skip to content

Commit 3bf2530

Browse files
authored
fix(f02-mocks): align mock with f02 as implemented (#19)
- StepWeightRecords is uncancellable; the discretionary path must not revoke a gate-originated write - weight records require floor <= v_start <= cap - SetShares rejects zero shares and duplicate recipients - methods below FRC-0042's floor are forbidden to EVM callers, not merely unhandled - GetState is no longer dispatched: reads move to a mockState() view plus an explicit mockSettle(), and the view does not settle, as f02 behaves
1 parent 701ab39 commit 3bf2530

6 files changed

Lines changed: 142 additions & 99 deletions

File tree

src/lib/FVMRewardMethod.sol

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
// SPDX-License-Identifier: Apache-2.0 OR MIT
22
pragma solidity ^0.8.36;
33

4-
// FRC-0042 method numbers for the f02 (Reward actor) stream-splitting methods proposed by
5-
// FIP-1270 (https://github.com/filecoin-project/FIPs/pull/1270) and tracked upstream at
6-
// filecoin-project/builtin-actors#1764. The actor does not exist yet; these numbers are the
7-
// FRC-0042 hash of each method name, defined here so FVMRewards and its mock agree on them.
4+
// FRC-0042 method numbers for the f02 (Reward actor) stream-splitting methods specified by
5+
// FIP-0118 (https://github.com/filecoin-project/FIPs/blob/master/FIPS/fip-0118.md) and implemented in
6+
// filecoin-project/builtin-actors#1764. Each is the FRC-0042 hash of the method's name. Defined
7+
// here so FVMRewards and its mock cannot disagree.
88
uint64 constant REGISTER_STREAM = 386660827;
99
uint64 constant REMOVE_STREAM = 1623858416;
1010
uint64 constant SET_WEIGHT_RECORDS = 3362570548;
@@ -13,7 +13,6 @@ uint64 constant SET_DISTRIBUTION = 3872725033;
1313
uint64 constant CANCEL_PENDING = 187585191;
1414
uint64 constant SET_SHARES = 2414422607;
1515
uint64 constant CLAIM = 4045527845;
16-
uint64 constant GET_STATE = 1397113977;
1716

1817
/// @dev The activation timelock SWA writes to f02 are queued under: the mainnet default,
1918
/// 7 days in epochs (30s/epoch); migration-set per network, so the real actor also exposes it

src/lib/FVMRewardTypes.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: Apache-2.0 OR MIT
22
pragma solidity ^0.8.36;
33

4-
/// @notice A stream's Distribution kind (FIP-1270 Section 2.4).
4+
/// @notice A stream's Distribution kind (FIP-0118 Section 2.4).
55
/// @dev IMPLICIT streams store no writer (f02 resolves the recipient from protocol state).
66
/// EXPLICIT streams are paid out per a wallet-to-share map written by their designated writer.
77
enum DistributionKind {

src/lib/FVMRewards.sol

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ import {
1818
} from "./FVMRewardMethod.sol";
1919
import {WeightRecord, DistributionKind, Share, PendingOp} from "./FVMRewardTypes.sol";
2020

21-
/// @notice Calls the f02 (Reward actor) methods proposed by FIP-1270, for the Stream Weight
22-
/// Actor (solstice#3) and Service Rewards Actor (solstice#4). f02 does not exist upstream yet
23-
/// (filecoin-project/builtin-actors#1764); the wire format here is this repo's best-effort
24-
/// CBOR encoding of the FIP's draft method signatures, not an upstream-confirmed ABI.
21+
/// @notice Calls the f02 (Reward actor) methods specified by FIP-0118, for the Stream Weight
22+
/// Actor (solstice#3) and Service Rewards Actor (solstice#4).
23+
/// @dev The param encodings are unverified against f02's own serialization vectors
24+
/// (builtin-actors `actors/reward/tests/types_test.rs`). Until they are checked against those,
25+
/// agreement with the mock in this repo is not evidence of agreement with the chain.
2526
/// @dev Every write params blob is a CBOR array of positional fields; addresses are encoded as
2627
/// CBOR byte strings wrapping an f410 delegated address (0x04, 0x0a, 20 address bytes). No
2728
/// abi.encode/abi.decode: params are hand-built in assembly and Filecoin BigInt returns are

test/mocks/FVMCallActorByIdWithReward.sol

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,15 @@ import {FVMCallActorById} from "fvm-solidity/mocks/FVMCallActorById.sol";
1111

1212
import {FVMRewardActor} from "./FVMRewardActor.sol";
1313

14-
/// @notice Extends fvm-solidity's CALL_ACTOR_BY_ID mock with a case for REWARD_ACTOR_ID.
15-
/// @dev fvm-solidity's FVMCallActorById has no branch for the reward actor (f02) and is not
16-
/// ours to modify -- these methods don't exist in builtin-actors yet (see
17-
/// FVMRewardActor.sol) and will only ever be called by solstice's own contracts. Rather
18-
/// than reimplement burn/power/datacap/miner handling here, this contract intercepts
19-
/// only REWARD_ACTOR_ID and forwards everything else, unmodified, to a freshly deployed
20-
/// FVMCallActorById via `delegatecall`. Using `delegatecall` (not `call`) is required: it
21-
/// is what preserves `address(this)`/`msg.sender` as the original caller all the way
22-
/// through to FVMCallActorById's `_handleBurn`, which debits `address(this).balance`
23-
/// expecting that to be the real caller's balance, not this contract's.
24-
/// @dev Etch this at CALL_ACTOR_BY_ID (replacing the vanilla FVMCallActorById) via
25-
/// MockRewardTest, after MockFVMTest.setUp() has already run.
14+
/// @notice Extends fvm-solidity's CALL_ACTOR_BY_ID mock with a branch for REWARD_ACTOR_ID (f02).
15+
/// @dev Serves calls addressed to f02 from FVMRewardActor and forwards every other actor id,
16+
/// unmodified, to an FVMCallActorById deployed at construction, leaving burn, power, datacap
17+
/// and miner behaviour with fvm-solidity.
18+
/// @dev That forward is a `delegatecall` and must stay one. It preserves `address(this)` and
19+
/// `msg.sender` as the original caller through to FVMCallActorById's `_handleBurn`, which
20+
/// debits `address(this).balance`; under `call` the debit lands on this contract instead.
21+
/// @dev Etch at CALL_ACTOR_BY_ID, replacing the vanilla FVMCallActorById, via MockRewardTest and
22+
/// after MockFVMTest.setUp() has run.
2623
contract FVMCallActorByIdWithReward {
2724
address private immutable BASE;
2825
FVMRewardActor private immutable REWARD;

test/mocks/FVMRewardActor.sol

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
SET_WEIGHT_RECORDS,
1212
STEP_WEIGHT_RECORDS,
1313
SET_SHARES,
14-
GET_STATE,
1514
REGISTER_STREAM,
1615
REMOVE_STREAM,
1716
SET_DISTRIBUTION,
@@ -24,10 +23,13 @@ import {WeightRecord, DistributionKind, Share, PendingOp} from "../../src/lib/FV
2423
/// @dev Weights, and per-orchestrator shares, are WAD-scaled: 1e18 == 1.0 == 100%.
2524
int256 constant WAD = 1e18;
2625

27-
/// @dev Mock-only caps; f02 requires these limits to exist but never fixes their values.
26+
/// @dev f02's caps, fixed by FIP-0118.
2827
uint64 constant MAX_STREAMS = 8;
2928
uint256 constant MAX_RECIPIENTS = 64;
3029

30+
/// @dev FRC-0042's floor. Below it a method is internal API, closed to EVM callers.
31+
uint64 constant FIRST_EXPORTED_METHOD_NUMBER = 1 << 24;
32+
3133
/// @dev Same value as WAD, typed uint256, so summing shares needs no signed-to-unsigned cast.
3234
uint256 constant SHARE_TOTAL = 1e18;
3335

@@ -36,8 +38,8 @@ struct LedgerRow {
3638
uint256 amount;
3739
}
3840

39-
/// @dev Enumerable, prunable address->uint256 balance -- plain mappings-plus-array, not the
40-
/// builtin-actor's CBOR-behind-a-CID shape (fine: nothing implements that wire format yet).
41+
/// @dev Enumerable, prunable address->uint256 balance, as plain mappings plus an array. This is
42+
/// not f02's on-chain shape, which is CBOR behind a CID, and no contract can read either one.
4143
struct Ledger {
4244
mapping(address => uint256) amount;
4345
mapping(address => uint256) indexPlusOne; // 0 == not tracked
@@ -103,11 +105,21 @@ struct PendingView {
103105
address writer;
104106
}
105107

108+
/// @notice Everything mockState reports, bundled so call sites don't juggle an 8-way tuple.
109+
struct MockState {
110+
uint256 totalMintedReward;
111+
uint256 totalBurnMinted;
112+
uint256 totalServiceMinted;
113+
uint64 nextTransitionEpoch;
114+
uint64 swaTimelockEpochs;
115+
StreamView[] streams;
116+
TombstoneView[] tombstones;
117+
PendingView[] pendingWrites;
118+
}
119+
106120
/// @notice Mock for the Filecoin Reward actor (f02), covering its stream-splitting methods.
107121
/// @dev Etch at REWARD_ACTOR_ADDRESS via MockRewardTest, which also re-etches CALL_ACTOR_BY_ID
108122
/// to reach handle_filecoin_method below.
109-
/// @dev GetState persists due writes rather than only projecting them; behaviorally identical
110-
/// once `effectiveEpoch` has passed.
111123
contract FVMRewardActor {
112124
/// @dev Survives vm.etch: immutables are baked into runtime bytecode at deploy time.
113125
Vm private immutable VM;
@@ -198,7 +210,7 @@ contract FVMRewardActor {
198210
emit BlockRewardAwarded(br, minerPortion, servicePortion, burnAmount);
199211
}
200212

201-
/// @notice Test helper: an EXPLICIT stream's wallet-to-share map, without the GetState round trip.
213+
/// @notice Test helper: an EXPLICIT stream's wallet-to-share map.
202214
function getShares(uint64 streamId) external view returns (Share[] memory) {
203215
return _streams[streamId].shares;
204216
}
@@ -214,7 +226,8 @@ contract FVMRewardActor {
214226
}
215227

216228
/// @notice Test helper: the clamp(v_start + slope*(e-t_start), floor, cap) math, exposed
217-
/// directly since it isn't a dispatched method (GetState already projects each weight).
229+
/// directly: an SWA has to mirror this schedule itself, and the mock is where a divergence
230+
/// between its copy and f02's should surface.
218231
function clampWeight(WeightRecord memory record, uint64 epoch) external pure returns (int256) {
219232
return _clampWeight(record, epoch);
220233
}
@@ -234,11 +247,14 @@ contract FVMRewardActor {
234247
external
235248
returns (uint32, uint64, bytes memory)
236249
{
250+
// restrict_internal_api: the internal API (AwardBlockReward, ThisEpochReward,
251+
// UpdateNetworkKPI, Constructor) is closed to EVM callers, and everything reaching a mock
252+
// through CALL_ACTOR_BY_ID is one. ThisEpochReward is not a back door.
253+
if (method < FIRST_EXPORTED_METHOD_NUMBER) return (USR_FORBIDDEN, 0, "");
237254
_settle();
238255
if (method == SET_WEIGHT_RECORDS) return _queueWeightWrite(PendingOp.SET_WEIGHT, params);
239256
if (method == STEP_WEIGHT_RECORDS) return _queueWeightWrite(PendingOp.STEP_WEIGHT, params);
240257
if (method == SET_SHARES) return _setShares(params);
241-
if (method == GET_STATE) return _getState();
242258
if (method == REGISTER_STREAM) return _registerStream(params);
243259
if (method == REMOVE_STREAM) return _removeStream(params);
244260
if (method == SET_DISTRIBUTION) return _setDistribution(params);
@@ -305,6 +321,10 @@ contract FVMRewardActor {
305321

306322
uint256 total;
307323
for (uint256 i = 0; i < newShares.length; i++) {
324+
if (newShares[i].share == 0) return (USR_ILLEGAL_ARGUMENT, 0, "");
325+
for (uint256 j = 0; j < i; j++) {
326+
if (newShares[j].wallet == newShares[i].wallet) return (USR_ILLEGAL_ARGUMENT, 0, "");
327+
}
308328
total += newShares[i].share;
309329
}
310330
if (total != SHARE_TOTAL) return (USR_ILLEGAL_ARGUMENT, 0, "");
@@ -318,7 +338,13 @@ contract FVMRewardActor {
318338
return (0, 0, "");
319339
}
320340

321-
function _getState() internal view returns (uint32, uint64, bytes memory) {
341+
/// @notice Test helper: the mock's whole state, read directly rather than through a method.
342+
/// @dev f02 exposes no reads at all, so an SWA or SRA must mirror anything it needs in its own
343+
/// state. Tests are not so constrained, and reading here keeps that asymmetry visible.
344+
/// @dev A true view: it does not settle. Advancing the epoch and reading without an
345+
/// intervening mutating call shows nothing applied, exactly as f02 behaves. Use mockSettle to
346+
/// apply due writes.
347+
function mockState() external view returns (MockState memory) {
322348
uint64 nowEpoch = uint64(block.number);
323349

324350
StreamView[] memory streams = new StreamView[](_streamIds.length);
@@ -358,20 +384,21 @@ contract FVMRewardActor {
358384
});
359385
}
360386

361-
return (
362-
0,
363-
0,
364-
abi.encode(
365-
totalMintedReward,
366-
totalBurnMinted,
367-
totalServiceMinted,
368-
nextTransitionEpoch,
369-
swaTimelockEpochs,
370-
streams,
371-
tombstones,
372-
pendingWrites
373-
)
374-
);
387+
return MockState({
388+
totalMintedReward: totalMintedReward,
389+
totalBurnMinted: totalBurnMinted,
390+
totalServiceMinted: totalServiceMinted,
391+
nextTransitionEpoch: nextTransitionEpoch,
392+
swaTimelockEpochs: swaTimelockEpochs,
393+
streams: streams,
394+
tombstones: tombstones,
395+
pendingWrites: pendingWrites
396+
});
397+
}
398+
399+
/// @notice Test helper: applies due writes, as f02 does at the head of every mutating call.
400+
function mockSettle() external {
401+
_settle();
375402
}
376403

377404
function _registerStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) {
@@ -465,6 +492,9 @@ contract FVMRewardActor {
465492
if (msg.sender != swa) return (USR_FORBIDDEN, 0, "");
466493
// Params CBOR: [id, op]
467494
(uint64 id, PendingOp op) = _decodeCancelPendingParams(params);
495+
// StepWeightRecords is the one uncancellable op: the discretionary path must not be able
496+
// to revoke a governance-gated write.
497+
if (op == PendingOp.STEP_WEIGHT) return (USR_ILLEGAL_ARGUMENT, 0, "");
468498
if (_pendingExists[id][op]) {
469499
delete _pending[id][op];
470500
_pendingExists[id][op] = false;
@@ -827,8 +857,10 @@ contract FVMRewardActor {
827857
}
828858

829859
/// @dev Per-record sanity required at write time: 0 <= floor <= cap <= 1.
860+
/// @dev validate_weight_record: floor <= v_start <= cap <= DENOM. The lower bound on floor
861+
/// is implicit in f02, where these three are u64; here they are signed and it is not.
830862
function _sane(WeightRecord memory w) internal pure returns (bool) {
831-
return w.floor >= 0 && w.floor <= w.cap && w.cap <= WAD;
863+
return w.floor >= 0 && w.floor <= w.cap && w.cap <= WAD && w.vStart >= w.floor && w.vStart <= w.cap;
832864
}
833865

834866
/// @dev Sum of every registered stream's weight at `atEpoch`, excluding `excludeIds`.

0 commit comments

Comments
 (0)