Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
904a142
fix(f02-mocks): match encoding format against f02's own vectors
rvagg Aug 10, 2026
60d6ccb
fix(FVMRewards): import EXIT_SUCCESS from fvm-solidity instead of red…
wjmelements Aug 10, 2026
bba6a4f
fix(f02): pair id with record so a batch's lengths can't mismatch
wjmelements Aug 10, 2026
fc63326
test(f02-mocks): use FVMAddress.maskedAddress instead of a local dupl…
wjmelements Aug 10, 2026
e13f769
test(f02-mocks): swap the wire test onto a params-recorder mock
wjmelements Aug 10, 2026
405d1e8
perf(f02): stop reserving worst-case memory for call params
wjmelements Aug 10, 2026
512c37f
perf(f02): inline the delegatecall success check in _invoke
wjmelements Aug 10, 2026
ccf971f
perf(f02): inline _writeNull at its two call sites
wjmelements Aug 10, 2026
1962eb0
perf(f02): move the varint loop in _writeIdAddress into one assembly …
wjmelements Aug 10, 2026
d54c174
perf(f02): read address as uint256 in assembly to skip a redundant mask
wjmelements Aug 10, 2026
e1ba0b8
perf(f02): use byte(1, mload(p)) in _readHead and cover its branch
wjmelements Aug 10, 2026
e808569
perf(f02): switch on the zero case in _writeHead's width dispatch
wjmelements Aug 11, 2026
95f8ef5
test(f02): cover every branch of _readBigInt
wjmelements Aug 11, 2026
285f610
perf(f02): fold _readBigInt's zero-length guard into assembly
wjmelements Aug 11, 2026
cd178db
perf(f02): fold _decodeAmounts's empty-payload check into assembly
wjmelements Aug 11, 2026
b331282
perf(f02): drop the redundant empty-array allocation in tryClaim
wjmelements Aug 11, 2026
417f933
fix(f02): split registerStream into implicit and explicit overloads
rvagg Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/FVMRewardTypes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ struct WeightRecord {
int256 cap;
}

/// @notice One entry in a SetWeightRecords/StepWeightRecords batch.
/// @dev Bundling the id with its record makes a batch's two lengths structurally equal -- there
/// is no encoding for a mismatch, so none needs to be checked for.
struct WeightRecordUpdate {
uint64 id;
WeightRecord record;
}

/// @notice One entry in an EXPLICIT stream's wallet-to-share map.
/// @dev Shares across a stream's map must sum to SHARE_TOTAL (1e18); a single share must
/// therefore fit uint64.
Expand Down
1,067 changes: 417 additions & 650 deletions src/lib/FVMRewards.sol

Large diffs are not rendered by default.

21 changes: 9 additions & 12 deletions test/mocks/FVMCallActorByIdWithReward.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,25 @@ pragma solidity ^0.8.36;
import {Vm} from "forge-std/Vm.sol";

import {CALL_ACTOR_BY_ID} from "fvm-solidity/FVMPrecompiles.sol";
import {REWARD_ACTOR_ID} from "fvm-solidity/FVMActors.sol";
import {REWARD_ACTOR_ID, REWARD_ACTOR_ADDRESS} from "fvm-solidity/FVMActors.sol";
import {NO_FLAGS, READONLY_FLAG} from "fvm-solidity/FVMFlags.sol";
import {USR_ILLEGAL_ARGUMENT} from "fvm-solidity/FVMErrors.sol";
import {FVMCallActorById} from "fvm-solidity/mocks/FVMCallActorById.sol";

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

/// @notice Extends fvm-solidity's CALL_ACTOR_BY_ID mock with a branch for REWARD_ACTOR_ID (f02).
/// @dev Serves calls addressed to f02 from FVMRewardActor and forwards every other actor id,
/// unmodified, to an FVMCallActorById deployed at construction, leaving burn, power, datacap
/// and miner behaviour with fvm-solidity.
/// @dev Serves calls addressed to f02 by raw-selector routing to whichever mock is etched at
/// REWARD_ACTOR_ADDRESS, so callers can swap it without a typed dependency. Forwards every
/// other actor id, unmodified, to an FVMCallActorById deployed at construction.
/// @dev That forward is a `delegatecall` and must stay one. It preserves `address(this)` and
/// `msg.sender` as the original caller through to FVMCallActorById's `_handleBurn`, which
/// debits `address(this).balance`; under `call` the debit lands on this contract instead.
/// @dev Etch at CALL_ACTOR_BY_ID, replacing the vanilla FVMCallActorById, via MockRewardTest and
/// after MockFVMTest.setUp() has run.
contract FVMCallActorByIdWithReward {
address private immutable BASE;
FVMRewardActor private immutable REWARD;

constructor(Vm vm, FVMRewardActor reward) {
constructor(Vm vm) {
BASE = address(new FVMCallActorById(vm));
REWARD = reward;
}

fallback() external payable {
Expand All @@ -47,9 +43,10 @@ contract FVMCallActorByIdWithReward {
// None of the reward actor's methods accept a value; reject rather than drop it.
response = abi.encode(USR_ILLEGAL_ARGUMENT, uint64(0), bytes(""));
} else {
(uint32 exitCode, uint64 outCodec, bytes memory rewardRet) =
REWARD.handle_filecoin_method(method, codec, params);
response = abi.encode(exitCode, outCodec, rewardRet);
(bool rewardOk, bytes memory rewardRet) = REWARD_ACTOR_ADDRESS.call(
abi.encodeWithSignature("handle_filecoin_method(uint64,uint64,bytes)", method, codec, params)
);
response = rewardOk ? rewardRet : abi.encode(USR_ILLEGAL_ARGUMENT, uint64(0), bytes(""));
}
assembly ("memory-safe") {
return(add(response, 0x20), mload(response))
Expand Down
247 changes: 164 additions & 83 deletions test/mocks/FVMRewardActor.sol

Large diffs are not rendered by default.

225 changes: 70 additions & 155 deletions test/mocks/FVMRewardActor.t.sol

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions test/mocks/FVMRewardParamsRecorder.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {CBOR_CODEC} from "fvm-solidity/FVMCodec.sol";

/// @notice Stand-in for f02 (Reward actor) that records the exact bytes it received instead of
/// acting on them. FVMRewardWireTest only checks the wire format FVMRewards sends against
/// f02's own vectors, so it has no need for FVMRewardActor's stateful business logic.
/// @dev Etch at REWARD_ACTOR_ADDRESS via MockRewardWireTest, which also re-etches CALL_ACTOR_BY_ID
/// to reach handle_filecoin_method below, exactly as MockRewardTest does for the full mock.
contract FVMRewardParamsRecorder {
/// @notice The params of the most recently dispatched call, exactly as they arrived.
bytes public mockLastParams;

/// @notice Test helper: return this blob from Claim verbatim, so a test can pin the decode
/// path against f02's own ClaimReturn vectors rather than against what this mock computes.
bytes public mockClaimReturnData;
bool public mockClaimReturnSet;

function mockSetClaimReturn(bytes calldata data) external {
mockClaimReturnData = data;
mockClaimReturnSet = true;
}

// forge-lint: disable-next-line(mixed-case-function)
function handle_filecoin_method(uint64, uint64, bytes calldata params)
external
returns (uint32, uint64, bytes memory)
{
mockLastParams = params;
if (mockClaimReturnSet) return (0, CBOR_CODEC, mockClaimReturnData);
return (0, 0, "");
}
}
257 changes: 257 additions & 0 deletions test/mocks/FVMRewardWire.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {FVMAddress} from "fvm-solidity/FVMAddress.sol";

import {MockRewardWireTest} from "./MockRewardWireTest.sol";
import {WeightRecord, WeightRecordUpdate, Share, PendingOp} from "../../src/lib/FVMRewardTypes.sol";
import {FVMRewards} from "../../src/lib/FVMRewards.sol";

/// @dev Tests to match with Rust `fil_actor_reward` tests/types_test.rs `mod serialization`, whose
/// vectors these hex literals are copied from. Go's mirror is
/// go-state-types/builtin/*/reward.
///
/// FVMRewards is the SWA's and SRA's production caller, and the mock in this repo decodes with the
/// mirror image of its encoder, so the two agree with each other whatever they emit. Only these
/// vectors, which come from f02 itself, can tell either of them apart from the chain.
///
/// Some vectors pin a shape f02 will refuse on inspection (an empty weight batch, for one). They
/// fix the encoding, not the admissibility.
contract FVMRewardWireTest is MockRewardWireTest {
function _record(int256 vStart, int256 slope, uint64 tStart, int256 floor, int256 cap)
internal
pure
returns (WeightRecord memory)
{
return WeightRecord({vStart: vStart, slope: slope, tStart: tStart, floor: floor, cap: cap});
}

function _sent() internal view returns (bytes memory) {
return rewardActor().mockLastParams();
}

// [[
// [23,[23,-24,23,0,23]], [24,[24,-25,24,0,24]],
// [255,[255,-256,255,0,255]], [256,[256,-257,256,0,256]],
// [65535,[65535,-65536,65535,0,65535]], [65536,[65536,-65537,65536,0,65536]],
// [4294967295,[4294967295,-4294967296,4294967295,0,4294967295]],
// [4294967296,[4294967296,-4294967297,4294967296,0,4294967296]]
// ]]
function test_SetWeightRecords_WalksEveryIntegerWidth() public {
uint64[8] memory v = [uint64(23), 24, 255, 256, 65_535, 65_536, 4_294_967_295, 4_294_967_296];
WeightRecordUpdate[] memory updates = new WeightRecordUpdate[](8);
for (uint256 i = 0; i < 8; i++) {
updates[i] = WeightRecordUpdate({
id: v[i],
record: _record(int256(uint256(v[i])), -int256(uint256(v[i])) - 1, v[i], 0, int256(uint256(v[i])))
});
}

FVMRewards.trySetWeightRecords(updates);
assertEq(
_sent(),
hex"81888217851737170017821818851818381818180018188218ff8518ff38ff18ff0018ff"
hex"8219010085190100390100190100001901008219ffff8519ffff39ffff19ffff0019ffff"
hex"821a00010000851a000100003a000100001a00010000001a00010000"
hex"821affffffff851affffffff3affffffff1affffffff001affffffff"
hex"821b0000000100000000851b00000001000000003b0000000100000000" hex"1b0000000100000000001b0000000100000000"
);
}

/// @dev Identical params, different dispatch. The two are separate calls because f02 will not
/// let the discretionary path cancel what the gate produced.
function test_StepWeightRecords_EncodesLikeSetWeightRecords() public {
WeightRecordUpdate[] memory updates = new WeightRecordUpdate[](1);
updates[0] = WeightRecordUpdate({id: 23, record: _record(23, -24, 23, 0, 23)});

FVMRewards.trySetWeightRecords(updates);
bytes memory set = _sent();
FVMRewards.tryStepWeightRecords(updates);
assertEq(_sent(), set);
}

// [[]] and one entry at the widest form. Step encodes identically to Set but is pinned
// separately, so this table stands alone as a description of the method.
function test_StepWeightRecords_EmptyAndBoundaryBatch() public {
FVMRewards.tryStepWeightRecords(new WeightRecordUpdate[](0));
assertEq(_sent(), hex"8180");

WeightRecordUpdate[] memory updates = new WeightRecordUpdate[](1);
updates[0] =
WeightRecordUpdate({id: 4_294_967_296, record: _record(4_294_967_296, -4_294_967_297, 65_536, 256, 1e18)});
FVMRewards.tryStepWeightRecords(updates);
assertEq(
_sent(),
hex"8181821b0000000100000000851b00000001000000003b0000000100000000" hex"1a000100001901001b0de0b6b3a7640000"
);
}

// [
// 4294967296,[4294967296,-4294967296,65536,256,1000000000000000000],
// [byte[040a1111111111111111111111111111111111111111],
// [[byte[008080808010],1000000000000000000]]],65536
// ]
function test_RegisterStream_ExplicitCarriesWriterAndInitialMap() public {
Share[] memory shares = new Share[](1);
shares[0] = Share({wallet: FVMAddress.maskedAddress(4_294_967_296), share: 1e18});
FVMRewards.tryRegisterStream(
4_294_967_296,
_record(4_294_967_296, -4_294_967_296, 65_536, 256, 1e18),
0x1111111111111111111111111111111111111111,
shares,
65_536
);
assertEq(
_sent(),
hex"841b0000000100000000851b00000001000000003affffffff1a000100001901"
hex"001b0de0b6b3a76400008256040a111111111111111111111111111111111111"
hex"11118182460080808080101b0de0b6b3a76400001a00010000"
);
}

// [24,[24,-24,256,0,65536],null,4294967296]
function test_RegisterStream_ImplicitIsANullDistribution() public {
FVMRewards.tryRegisterStream(24, _record(24, -24, 256, 0, 65_536), address(0), new Share[](0), 4_294_967_296);
assertEq(_sent(), hex"84181885181837190100001a00010000f61b0000000100000000");
}

// [24] and [256] -- a single-field parameter tuple is still an array.
function test_RemoveStream_WrapsTheIdInAnArray() public {
FVMRewards.tryRemoveStream(24);
assertEq(_sent(), hex"811818");
FVMRewards.tryRemoveStream(256);
assertEq(_sent(), hex"81190100");
FVMRewards.tryRemoveStream(65_536);
assertEq(_sent(), hex"811a00010000");
FVMRewards.tryRemoveStream(4_294_967_296);
assertEq(_sent(), hex"811b0000000100000000");
}

// [24,byte[008080808010]] and [256,byte[040a1111...1111]] -- one writer in each address form.
function test_SetDistribution_CarriesOnlyTheWriter() public {
FVMRewards.trySetDistribution(24, FVMAddress.maskedAddress(4_294_967_296));
assertEq(_sent(), hex"82181846008080808010");
FVMRewards.trySetDistribution(256, 0x1111111111111111111111111111111111111111);
assertEq(_sent(), hex"8219010056040a1111111111111111111111111111111111111111");
}

// [24,[]]
function test_SetShares_EmptyMap() public {
FVMRewards.trySetShares(24, new Share[](0));
assertEq(_sent(), hex"82181880");
}

// [
// 256,[[byte[0018],24],[byte[008002],256],
// [byte[00808004],65536],[byte[008080808010],4294967296]]
// ]
function test_SetShares_WalksEveryIntegerWidth() public {
uint64[4] memory v = [uint64(24), 256, 65_536, 4_294_967_296];
Share[] memory shares = new Share[](4);
for (uint256 i = 0; i < 4; i++) {
shares[i] = Share({wallet: FVMAddress.maskedAddress(v[i]), share: v[i]});
}
FVMRewards.trySetShares(256, shares);
assertEq(
_sent(),
hex"821901008482420018181882430080021901008244008080041a00010000" hex"82460080808080101b0000000100000000"
);
}

/// @dev The cap, and the only case where an array header needs two bytes.
// [65536,64 * [byte[f01000..f01063],15625000000000000]]
function test_SetShares_MaxRecipients() public {
Share[] memory shares = new Share[](64);
for (uint64 i = 0; i < 64; i++) {
shares[i] = Share({wallet: FVMAddress.maskedAddress(1000 + i), share: 1e18 / 64});
}
FVMRewards.trySetShares(65_536, shares);
assertEq(
_sent(),
hex"821a000100009840824300e8071b003782dace9d9000824300e9071b003782da"
hex"ce9d9000824300ea071b003782dace9d9000824300eb071b003782dace9d9000"
hex"824300ec071b003782dace9d9000824300ed071b003782dace9d9000824300ee"
hex"071b003782dace9d9000824300ef071b003782dace9d9000824300f0071b0037"
hex"82dace9d9000824300f1071b003782dace9d9000824300f2071b003782dace9d"
hex"9000824300f3071b003782dace9d9000824300f4071b003782dace9d90008243"
hex"00f5071b003782dace9d9000824300f6071b003782dace9d9000824300f7071b"
hex"003782dace9d9000824300f8071b003782dace9d9000824300f9071b003782da"
hex"ce9d9000824300fa071b003782dace9d9000824300fb071b003782dace9d9000"
hex"824300fc071b003782dace9d9000824300fd071b003782dace9d9000824300fe"
hex"071b003782dace9d9000824300ff071b003782dace9d900082430080081b0037"
hex"82dace9d900082430081081b003782dace9d900082430082081b003782dace9d"
hex"900082430083081b003782dace9d900082430084081b003782dace9d90008243"
hex"0085081b003782dace9d900082430086081b003782dace9d900082430087081b"
hex"003782dace9d900082430088081b003782dace9d900082430089081b003782da"
hex"ce9d90008243008a081b003782dace9d90008243008b081b003782dace9d9000"
hex"8243008c081b003782dace9d90008243008d081b003782dace9d90008243008e"
hex"081b003782dace9d90008243008f081b003782dace9d900082430090081b0037"
hex"82dace9d900082430091081b003782dace9d900082430092081b003782dace9d"
hex"900082430093081b003782dace9d900082430094081b003782dace9d90008243"
hex"0095081b003782dace9d900082430096081b003782dace9d900082430097081b"
hex"003782dace9d900082430098081b003782dace9d900082430099081b003782da"
hex"ce9d90008243009a081b003782dace9d90008243009b081b003782dace9d9000"
hex"8243009c081b003782dace9d90008243009d081b003782dace9d90008243009e"
hex"081b003782dace9d90008243009f081b003782dace9d9000824300a0081b0037"
hex"82dace9d9000824300a1081b003782dace9d9000824300a2081b003782dace9d"
hex"9000824300a3081b003782dace9d9000824300a4081b003782dace9d90008243"
hex"00a5081b003782dace9d9000824300a6081b003782dace9d9000824300a7081b" hex"003782dace9d9000"
);
}

/// @dev The one vector on the decode side. `_decodeAmounts` reads a Filecoin BigInt per wallet,
/// where an empty byte string is zero and anything else is a sign byte then a big-endian
/// magnitude, so the widths here are what that parser has to get right.
// [[byte[],byte[0018],byte[000100],byte[00010000],byte[000100000000]]]
function test_ClaimReturn_DecodesEveryAmountWidth() public {
rewardActor().mockSetClaimReturn(hex"81854042001843000100440001000046000100000000");
(int256 exitCode, uint256[] memory amounts) = FVMRewards.tryClaim(1, new address[](5));
assertEq(exitCode, 0);
assertEq(amounts.length, 5);
assertEq(amounts[0], 0);
assertEq(amounts[1], 24);
assertEq(amounts[2], 256);
assertEq(amounts[3], 65_536);
assertEq(amounts[4], 4_294_967_296);
}

// [[]] -- a claim for nobody decodes to an empty array, not a revert.
function test_ClaimReturn_Empty() public {
rewardActor().mockSetClaimReturn(hex"8180");
(int256 exitCode, uint256[] memory amounts) = FVMRewards.tryClaim(1, new address[](0));
assertEq(exitCode, 0);
assertEq(amounts.length, 0);
}

// [null,op] for the two schedule-wide weight slots, [id,op] for everything per stream.
function test_CancelPending_NullIdAddressesTheWeightSlot() public {
FVMRewards.tryCancelPendingWeight(PendingOp.SET_WEIGHT);
assertEq(_sent(), hex"82f600");
FVMRewards.tryCancelPending(24, PendingOp.REGISTER);
assertEq(_sent(), hex"82181802");
FVMRewards.tryCancelPending(256, PendingOp.REMOVE);
assertEq(_sent(), hex"8219010003");
FVMRewards.tryCancelPendingWeight(PendingOp.STEP_WEIGHT);
assertEq(_sent(), hex"82f601");
FVMRewards.tryCancelPending(65_536, PendingOp.SET_DISTRIBUTION);
assertEq(_sent(), hex"821a0001000004");
}

// [4294967296,[]]
function test_Claim_EmptyBatch() public {
FVMRewards.tryClaim(4_294_967_296, new address[](0));
assertEq(_sent(), hex"821b000000010000000080");
}

/// @dev The two address forms in one batch. A masked ID must not be emitted as f410: that
/// names a delegated address nobody created, so it resolves nowhere and f02 rejects the call.
/// f099, the burn actor the freeze rule pays into, is reachable only through the first form.
// [65536,[byte[008080808010],byte[040a1111111111111111111111111111111111111111]]]
function test_Claim_EncodesBothAddressForms() public {
address[] memory wallets = new address[](2);
wallets[0] = FVMAddress.maskedAddress(4_294_967_296);
wallets[1] = 0x1111111111111111111111111111111111111111;
FVMRewards.tryClaim(65_536, wallets);
assertEq(_sent(), hex"821a000100008246008080808010" hex"56040a1111111111111111111111111111111111111111");
}
}
4 changes: 1 addition & 3 deletions test/mocks/MockRewardTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@ contract MockRewardTest is MockFVMTest {
super.setUp();
vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardActor(vm)).code);
FVMRewardActor(REWARD_ACTOR_ADDRESS).mockInit();
vm.etch(
CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm, FVMRewardActor(REWARD_ACTOR_ADDRESS))).code
);
vm.etch(CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm)).code);
}

function rewardActor() internal pure returns (FVMRewardActor) {
Expand Down
23 changes: 23 additions & 0 deletions test/mocks/MockRewardWireTest.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {MockFVMTest} from "fvm-solidity/mocks/MockFVMTest.sol";
import {CALL_ACTOR_BY_ID} from "fvm-solidity/FVMPrecompiles.sol";
import {REWARD_ACTOR_ADDRESS} from "fvm-solidity/FVMActors.sol";

import {FVMCallActorByIdWithReward} from "./FVMCallActorByIdWithReward.sol";
import {FVMRewardParamsRecorder} from "./FVMRewardParamsRecorder.sol";

/// @notice Etches a params-recording stand-in for f02 rather than the full stateful mock: the
/// wire-format tests only need the exact bytes FVMRewards sent, not f02's business logic.
contract MockRewardWireTest is MockFVMTest {
function setUp() public virtual override {
super.setUp();
vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardParamsRecorder()).code);
vm.etch(CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm)).code);
}

function rewardActor() internal pure returns (FVMRewardParamsRecorder) {
return FVMRewardParamsRecorder(REWARD_ACTOR_ADDRESS);
}
}