From 2942b78f7e4fade345e7a179fa6afda16e9ef43b Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 27 Jul 2026 18:25:30 -0500 Subject: [PATCH 01/14] wip(f02): mock f02 stream-splitting methods for FIP-0118 Assisted-by: Claude:claude-sonnet-5 --- .gitmodules | 3 + foundry.lock | 5 +- foundry.toml | 3 +- lib/fvm-solidity | 1 + test/mocks/FVMCallActorByIdWithReward.sol | 65 +++ test/mocks/FVMRewardActor.sol | 443 ++++++++++++++++ test/mocks/FVMRewardActor.t.sol | 614 ++++++++++++++++++++++ test/mocks/FVMRewardMethod.sol | 21 + test/mocks/MockRewardTest.sol | 27 + 9 files changed, 1180 insertions(+), 2 deletions(-) create mode 160000 lib/fvm-solidity create mode 100644 test/mocks/FVMCallActorByIdWithReward.sol create mode 100644 test/mocks/FVMRewardActor.sol create mode 100644 test/mocks/FVMRewardActor.t.sol create mode 100644 test/mocks/FVMRewardMethod.sol create mode 100644 test/mocks/MockRewardTest.sol diff --git a/.gitmodules b/.gitmodules index 19d3c08..8cdbdb0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,9 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/fvm-solidity"] + path = lib/fvm-solidity + url = https://github.com/filecoin-project/fvm-solidity [submodule "lib/safe-smart-account"] path = lib/safe-smart-account url = https://github.com/safe-fndn/safe-smart-account diff --git a/foundry.lock b/foundry.lock index 518ec4e..ee5735f 100644 --- a/foundry.lock +++ b/foundry.lock @@ -5,10 +5,13 @@ "rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b" } }, + "lib/fvm-solidity": { + "rev": "ea1fe65367d7539236be111916a6e2781bcf7a1b" + }, "lib/safe-smart-account": { "tag": { "name": "v1.5.0", "rev": "dc437e8fba8b4805d76bcbd1c668c9fd3d1e83be" } } -} \ No newline at end of file +} diff --git a/foundry.toml b/foundry.toml index f8344f3..fba2f54 100644 --- a/foundry.toml +++ b/foundry.toml @@ -14,8 +14,8 @@ bytecode_hash = "none" # For dependencies remappings = [ 'forge-std/=lib/forge-std/src/', + 'fvm-solidity/=lib/fvm-solidity/src/', '@safe/=lib/safe-smart-account/contracts/', - #'@fvm-solidity/=lib/pdp/lib/fvm-solidity/src/', ] [lint] @@ -23,5 +23,6 @@ severity = ["high", "med", "low", "info", "gas", "code-size"] exclude_lints = [ "incorrect-shift", "multi-contract-file", + "unsafe-typecast", "unwrapped-modifier-logic", ] diff --git a/lib/fvm-solidity b/lib/fvm-solidity new file mode 160000 index 0000000..ea1fe65 --- /dev/null +++ b/lib/fvm-solidity @@ -0,0 +1 @@ +Subproject commit ea1fe65367d7539236be111916a6e2781bcf7a1b diff --git a/test/mocks/FVMCallActorByIdWithReward.sol b/test/mocks/FVMCallActorByIdWithReward.sol new file mode 100644 index 0000000..c91c7aa --- /dev/null +++ b/test/mocks/FVMCallActorByIdWithReward.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +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 {NO_FLAGS, READONLY_FLAG} from "fvm-solidity/FVMFlags.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 case for REWARD_ACTOR_ID. +/// @dev fvm-solidity's FVMCallActorById has no branch for the reward actor (f02) and is not +/// ours to modify -- these methods don't exist in builtin-actors yet (see +/// FVMRewardActor.sol) and will only ever be called by solstice's own contracts. Rather +/// than reimplement burn/power/datacap/miner handling here, this contract intercepts +/// only REWARD_ACTOR_ID and forwards everything else, unmodified, to a freshly deployed +/// FVMCallActorById via `delegatecall`. Using `delegatecall` (not `call`) is required: it +/// is what preserves `address(this)`/`msg.sender` as the original caller all the way +/// through to FVMCallActorById's `_handleBurn`, which debits `address(this).balance` +/// expecting that to be the real caller's balance, not this contract's. +/// @dev Etch this at CALL_ACTOR_BY_ID (replacing the vanilla FVMCallActorById) via +/// MockRewardTest, after MockFVMTest.setUp() has already run. +contract FVMCallActorByIdWithReward { + address private immutable BASE; + FVMRewardActor private immutable REWARD; + + constructor(Vm vm, FVMRewardActor reward) { + BASE = address(new FVMCallActorById(vm)); + REWARD = reward; + } + + fallback() external payable { + // Real precompile requires delegatecall; call/staticcall returns CallForbidden → (0, empty). + if (address(this) == CALL_ACTOR_BY_ID) { + assembly ("memory-safe") { + revert(0, 0) + } + } + + (uint64 method,, uint64 flags, uint64 codec, bytes memory params, uint64 actorId) = + abi.decode(msg.data, (uint64, uint256, uint64, uint64, bytes, uint64)); + + if (actorId == REWARD_ACTOR_ID) { + require(flags == READONLY_FLAG || flags == NO_FLAGS, "FVMCallActorByIdWithReward: invalid flags"); + (uint32 exitCode, uint64 outCodec, bytes memory rewardRet) = + REWARD.handle_filecoin_method(method, codec, params); + bytes memory response = abi.encode(exitCode, outCodec, rewardRet); + assembly ("memory-safe") { + return(add(response, 0x20), mload(response)) + } + } + + (bool ok, bytes memory baseRet) = BASE.delegatecall(msg.data); + if (!ok) { + assembly ("memory-safe") { + revert(add(baseRet, 0x20), mload(baseRet)) + } + } + assembly ("memory-safe") { + return(add(baseRet, 0x20), mload(baseRet)) + } + } +} diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol new file mode 100644 index 0000000..740cf87 --- /dev/null +++ b/test/mocks/FVMRewardActor.sol @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol"; + +import { + SET_WEIGHT_RECORDS, + SET_SHARES, + GET_STATE, + REGISTER_STREAM, + REMOVE_STREAM, + SET_DISTRIBUTION, + CANCEL_PENDING, + COMPUTE_WEIGHT, + SWA_TIMELOCK +} from "./FVMRewardMethod.sol"; + +/// @dev Weights, and per-orchestrator shares, are WAD-scaled: 1e18 == 1.0 == 100%. +int256 constant WAD = 1e18; + +/// @dev Mock-only caps; FIP-0118 requires MAX_STREAMS/MAX_RECIPIENTS limits exist but does +/// not fix their values, so these are placeholders sized for testing, not consensus values. +uint256 constant MAX_STREAMS = 8; +uint256 constant MAX_RECIPIENTS = 32; + +/// @dev Same value as WAD, typed uint256: shares (always non-negative) are stored unsigned, +/// so comparing their sum against this avoids a signed-to-unsigned cast of WAD. +uint256 constant SHARE_TOTAL = 1e18; + +/// @notice FIP-0118 section 2.4: the bundle of scheduler parameters for one stream's weight. +/// @dev vStart/slope/floor/cap are WAD-scaled Rationals; slope may be negative (w1's ramp). +struct WeightRecord { + int256 vStart; + int256 slope; + uint64 tStart; + int256 floor; + int256 cap; +} + +/// @notice A stream's Distribution: IMPLICIT (consensus stream only, f02-resolved recipient) +/// or EXPLICIT (a wallet-to-share map maintained by the stream's designated writer). +enum DistributionKind { + IMPLICIT, + EXPLICIT +} + +/// @notice One entry in an EXPLICIT stream's wallet-to-share map. +struct Share { + address wallet; + uint256 share; +} + +/// @notice FIP-0118 section 2.4: `Stream = { id, WeightRecord, Distribution }`. `id` is the +/// mapping key rather than a field here; `shares` is the EXPLICIT distribution's +/// wallet-to-share map (unused when `kind == IMPLICIT`). +struct Stream { + bool exists; + WeightRecord weightRecord; + DistributionKind kind; + address writer; + Share[] shares; +} + +enum PendingKind { + NONE, + REGISTER, + SET_WEIGHT, + SET_DISTRIBUTION, + REMOVE +} + +/// @dev A queued SWA write, held until `effectiveEpoch` per SWA_TIMELOCK. Only one pending +/// write per stream id at a time -- a second SWA write for the same id simply replaces it, +/// matching CancelPending's premise that there is a single queued write to discard. +struct Pending { + PendingKind kind; + uint64 effectiveEpoch; + WeightRecord weightRecord; + DistributionKind distributionKind; + address writer; +} + +/// @notice Mock for the Filecoin Reward actor (f02) covering the stream-splitting methods +/// proposed by draft FIP-0118 (https://github.com/filecoin-project/FIPs/pull/1270, +/// tracked in https://github.com/filecoin-project/builtin-actors/issues/1764): +/// SetWeightRecords, SetShares, GetState, RegisterStream, RemoveStream, +/// SetDistribution, CancelPending, and ComputeWeight. +/// @dev These mocks live in solstice, not fvm-solidity, because they mock methods that don't +/// exist in builtin-actors yet and will only ever be called by solstice's own contracts +/// (the future Stream Weights Actor and Service Rewards Actor). +/// @dev Etch this at REWARD_ACTOR_ADDRESS via MockRewardTest, which also re-etches +/// CALL_ACTOR_BY_ID with FVMCallActorByIdWithReward so that +/// CALL_ACTOR_BY_ID(actorId=REWARD_ACTOR_ID) reaches handle_filecoin_method below. +/// @dev Params/returns use plain abi.encode/abi.decode rather than CBOR: the real wire format +/// doesn't exist yet since builtin-actors hasn't implemented these methods, so there is +/// nothing to match. Revisit the encoding once the real actor ships. +contract FVMRewardActor { + /// @notice The address authorized to call the SWA-only methods (SetWeightRecords, + /// RegisterStream, RemoveStream, SetDistribution, CancelPending). + address public swa; + + mapping(uint64 streamId => Stream) internal _streams; + uint64[] internal _streamIds; + + mapping(uint64 streamId => Pending) internal _pending; + uint64[] internal _pendingIds; + + /// @notice Test helper: set the address authorized to call SWA-only methods. + function mockSwa(address swa_) external { + swa = swa_; + } + + /// @notice Test helper: read back an EXPLICIT stream's wallet-to-share map directly, + /// without going through the CALL_ACTOR_BY_ID/CBOR-shaped GetState round trip. + function getShares(uint64 streamId) external view returns (Share[] memory) { + return _streams[streamId].shares; + } + + /// @notice Fallback for unknown ABI selectors, matching the real reward actor's behavior + /// for direct EVM CALL (InvokeContract): it's a native actor, so this returns + /// USR_UNHANDLED_MESSAGE rather than reverting. + fallback() external { + bytes memory response = abi.encode(uint32(USR_UNHANDLED_MESSAGE), uint64(0), bytes("")); + assembly ("memory-safe") { + return(add(response, 0x20), mload(response)) + } + } + + /// @dev Receives calls routed from FVMCallActorByIdWithReward's REWARD_ACTOR_ID branch. + /// Returns (exitCode, codec, data); never reverts for actor-level errors, matching real + /// FVM behavior where CALL_ACTOR_BY_ID returns success=true with a non-zero exit code. + // forge-lint: disable-next-line(mixed-case-function) + function handle_filecoin_method(uint64 method, uint64, bytes calldata params) + external + returns (uint32, uint64, bytes memory) + { + _settle(); + if (method == SET_WEIGHT_RECORDS) return _setWeightRecords(params); + if (method == SET_SHARES) return _setShares(params); + if (method == GET_STATE) return _getState(); + if (method == REGISTER_STREAM) return _registerStream(params); + if (method == REMOVE_STREAM) return _removeStream(params); + if (method == SET_DISTRIBUTION) return _setDistribution(params); + if (method == CANCEL_PENDING) return _cancelPending(params); + if (method == COMPUTE_WEIGHT) return _computeWeight(params); + return (USR_UNHANDLED_MESSAGE, 0, ""); + } + + // ------------------------------------------------------------------------- + // SetWeightRecords(ids, records) -- SWA only, queued under SWA_TIMELOCK + // ------------------------------------------------------------------------- + + function _setWeightRecords(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + (uint64[] memory ids, WeightRecord[] memory records) = abi.decode(params, (uint64[], WeightRecord[])); + if (ids.length != records.length) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + uint64 effectiveEpoch = uint64(block.number) + SWA_TIMELOCK; + for (uint256 i = 0; i < ids.length; i++) { + if (!_streams[ids[i]].exists) return (USR_NOT_FOUND, 0, ""); + if (!_sane(records[i])) return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + // Guardrail (FIP-0118 2.4): sum of all *other* streams' current weight, plus the + // newly proposed weights, must not exceed 1 at the activation epoch. + int256 sum = _sumWeightsExcluding(ids, effectiveEpoch); + for (uint256 i = 0; i < records.length; i++) { + sum += _clampWeight(records[i], effectiveEpoch); + } + if (sum > WAD) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + for (uint256 i = 0; i < ids.length; i++) { + _queue( + ids[i], + Pending({ + kind: PendingKind.SET_WEIGHT, + effectiveEpoch: effectiveEpoch, + weightRecord: records[i], + distributionKind: DistributionKind.IMPLICIT, + writer: address(0) + }) + ); + } + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // SetShares(id, shares) -- the stream's designated writer only, applied immediately + // ------------------------------------------------------------------------- + + function _setShares(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + (uint64 id, Share[] memory shares) = abi.decode(params, (uint64, Share[])); + Stream storage s = _streams[id]; + if (!s.exists) return (USR_NOT_FOUND, 0, ""); + if (s.kind != DistributionKind.EXPLICIT) return (USR_ILLEGAL_ARGUMENT, 0, ""); + if (msg.sender != s.writer) return (USR_FORBIDDEN, 0, ""); + if (shares.length > MAX_RECIPIENTS) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + uint256 total; + for (uint256 i = 0; i < shares.length; i++) { + total += shares[i].share; + } + if (total != SHARE_TOTAL) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + delete s.shares; + for (uint256 i = 0; i < shares.length; i++) { + s.shares.push(shares[i]); + } + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // GetState() -- read-only + // ------------------------------------------------------------------------- + + function _getState() internal view returns (uint32, uint64, bytes memory) { + uint256 n = _streamIds.length; + uint64[] memory ids = new uint64[](n); + WeightRecord[] memory records = new WeightRecord[](n); + DistributionKind[] memory kinds = new DistributionKind[](n); + address[] memory writers = new address[](n); + int256[] memory weights = new int256[](n); + + uint64 nowEpoch = uint64(block.number); + for (uint256 i = 0; i < n; i++) { + uint64 id = _streamIds[i]; + Stream storage s = _streams[id]; + ids[i] = id; + records[i] = s.weightRecord; + kinds[i] = s.kind; + writers[i] = s.writer; + weights[i] = _clampWeight(s.weightRecord, nowEpoch); + } + return (0, 0, abi.encode(ids, records, kinds, writers, weights)); + } + + // ------------------------------------------------------------------------- + // RegisterStream(id, weightRecord, kind, writer, activationEpoch) -- SWA only + // ------------------------------------------------------------------------- + + function _registerStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + (uint64 id, WeightRecord memory record, DistributionKind kind, address writer, uint64 activationEpoch) = + abi.decode(params, (uint64, WeightRecord, DistributionKind, address, uint64)); + + if (_streams[id].exists || _pending[id].kind == PendingKind.REGISTER) { + return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + // Count queued-but-not-yet-settled registrations too: otherwise a burst of + // RegisterStream calls within one SWA_TIMELOCK window could blow past the cap, since + // none of them would be in _streamIds yet. + if (_streamIds.length + _pendingRegistrationCount() >= MAX_STREAMS) { + return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + if (!_sane(record)) return (USR_ILLEGAL_ARGUMENT, 0, ""); + // IMPLICIT is consensus-only and carries no writer; EXPLICIT always needs one. + if (kind == DistributionKind.IMPLICIT ? writer != address(0) : writer == address(0)) { + return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + if (activationEpoch < uint64(block.number) + SWA_TIMELOCK) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + int256 sum = _sumWeightsExcluding(new uint64[](0), activationEpoch) + _clampWeight(record, activationEpoch); + if (sum > WAD) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + _queue( + id, + Pending({ + kind: PendingKind.REGISTER, + effectiveEpoch: activationEpoch, + weightRecord: record, + distributionKind: kind, + writer: writer + }) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // RemoveStream(id) -- SWA only, queued under SWA_TIMELOCK + // ------------------------------------------------------------------------- + + function _removeStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + uint64 id = abi.decode(params, (uint64)); + if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); + + _queue( + id, + Pending({ + kind: PendingKind.REMOVE, + effectiveEpoch: uint64(block.number) + SWA_TIMELOCK, + weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), + distributionKind: DistributionKind.IMPLICIT, + writer: address(0) + }) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // SetDistribution(id, kind, writer) -- SWA only, queued under SWA_TIMELOCK + // ------------------------------------------------------------------------- + + function _setDistribution(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + (uint64 id, DistributionKind kind, address writer) = abi.decode(params, (uint64, DistributionKind, address)); + if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); + // "Converting a stream to IMPLICIT is not permitted (IMPLICIT is consensus-only)." + if (kind == DistributionKind.IMPLICIT || writer == address(0)) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + _queue( + id, + Pending({ + kind: PendingKind.SET_DISTRIBUTION, + effectiveEpoch: uint64(block.number) + SWA_TIMELOCK, + weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), + distributionKind: kind, + writer: writer + }) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // CancelPending(id) -- SWA only + // ------------------------------------------------------------------------- + + function _cancelPending(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + uint64 id = abi.decode(params, (uint64)); + if (_pending[id].kind == PendingKind.NONE) return (USR_NOT_FOUND, 0, ""); + + delete _pending[id]; + _removePendingId(id); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // ComputeWeight(record, epoch) -- pure + // ------------------------------------------------------------------------- + + function _computeWeight(bytes calldata params) internal pure returns (uint32, uint64, bytes memory) { + (WeightRecord memory record, uint64 epoch) = abi.decode(params, (WeightRecord, uint64)); + return (0, 0, abi.encode(_clampWeight(record, epoch))); + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /// @dev FIP-0118 2.4: `clamp(v_start + slope * (e - t_start), floor, cap)`. + function _clampWeight(WeightRecord memory w, uint64 e) internal pure returns (int256 weight) { + int256 raw = w.vStart + w.slope * (int256(uint256(e)) - int256(uint256(w.tStart))); + weight = raw < w.floor ? w.floor : (raw > w.cap ? w.cap : raw); + } + + /// @dev Per-record sanity required at write time: 0 <= floor <= cap <= 1. + function _sane(WeightRecord memory w) internal pure returns (bool) { + return w.floor >= 0 && w.floor <= w.cap && w.cap <= WAD; + } + + /// @dev Sum of every *registered* stream's weight at `atEpoch`, excluding `excludeIds`. + /// Streams with a not-yet-settled pending write still count at their currently stored + /// (pre-write) record -- the invariant check is against what is live today, matching + /// f02's "validates ... at the activation epoch" rule for the write being proposed now. + function _sumWeightsExcluding(uint64[] memory excludeIds, uint64 atEpoch) internal view returns (int256 sum) { + for (uint256 i = 0; i < _streamIds.length; i++) { + uint64 id = _streamIds[i]; + bool excluded = false; + for (uint256 j = 0; j < excludeIds.length; j++) { + if (excludeIds[j] == id) { + excluded = true; + break; + } + } + if (!excluded) sum += _clampWeight(_streams[id].weightRecord, atEpoch); + } + } + + function _pendingRegistrationCount() internal view returns (uint256 count) { + for (uint256 i = 0; i < _pendingIds.length; i++) { + if (_pending[_pendingIds[i]].kind == PendingKind.REGISTER) count++; + } + } + + function _queue(uint64 id, Pending memory p) internal { + if (_pending[id].kind == PendingKind.NONE) _pendingIds.push(id); + _pending[id] = p; + } + + function _removePendingId(uint64 id) internal { + for (uint256 i = 0; i < _pendingIds.length; i++) { + if (_pendingIds[i] == id) { + _pendingIds[i] = _pendingIds[_pendingIds.length - 1]; + _pendingIds.pop(); + return; + } + } + } + + /// @dev Applies every queued write whose effectiveEpoch has arrived. Called at the top + /// of every dispatched method so stored state is always current before it's read or + /// validated against. + function _settle() internal { + uint64 nowEpoch = uint64(block.number); + for (uint256 i = 0; i < _pendingIds.length;) { + uint64 id = _pendingIds[i]; + Pending storage p = _pending[id]; + if (nowEpoch >= p.effectiveEpoch) { + _apply(id, p); + delete _pending[id]; + _pendingIds[i] = _pendingIds[_pendingIds.length - 1]; + _pendingIds.pop(); + continue; + } + i++; + } + } + + function _apply(uint64 id, Pending storage p) internal { + if (p.kind == PendingKind.REGISTER) { + Stream storage s = _streams[id]; + s.exists = true; + s.weightRecord = p.weightRecord; + s.kind = p.distributionKind; + s.writer = p.writer; + _streamIds.push(id); + } else if (p.kind == PendingKind.SET_WEIGHT) { + _streams[id].weightRecord = p.weightRecord; + } else if (p.kind == PendingKind.SET_DISTRIBUTION) { + _streams[id].kind = p.distributionKind; + _streams[id].writer = p.writer; + } else if (p.kind == PendingKind.REMOVE) { + delete _streams[id]; + for (uint256 j = 0; j < _streamIds.length; j++) { + if (_streamIds[j] == id) { + _streamIds[j] = _streamIds[_streamIds.length - 1]; + _streamIds.pop(); + break; + } + } + } + } +} diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol new file mode 100644 index 0000000..8053ea7 --- /dev/null +++ b/test/mocks/FVMRewardActor.t.sol @@ -0,0 +1,614 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +import {REWARD_ACTOR_ID, REWARD_ACTOR_ADDRESS, BURN_ACTOR_ID, BURN_ADDRESS} from "fvm-solidity/FVMActors.sol"; +import {CALL_ACTOR_BY_ID} from "fvm-solidity/FVMPrecompiles.sol"; +import {NO_FLAGS} from "fvm-solidity/FVMFlags.sol"; +import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol"; +import {FVMPay} from "fvm-solidity/FVMPay.sol"; +import {FVMMiner} from "fvm-solidity/FVMMiner.sol"; + +import {MockRewardTest} from "./MockRewardTest.sol"; +import { + WeightRecord, + DistributionKind, + Share, + WAD, + MAX_STREAMS, + MAX_RECIPIENTS, + SHARE_TOTAL +} from "./FVMRewardActor.sol"; +import { + SET_WEIGHT_RECORDS, + SET_SHARES, + GET_STATE, + REGISTER_STREAM, + REMOVE_STREAM, + SET_DISTRIBUTION, + CANCEL_PENDING, + COMPUTE_WEIGHT, + SWA_TIMELOCK +} from "./FVMRewardMethod.sol"; + +/// @dev A distinct external caller, so a test can give SWA / stream-writer authorization to +/// an address other than the test contract itself and check that authorization is enforced +/// by identity, not by happenstance of who the test contract is. +contract RewardCaller { + function call(uint64 method, bytes memory params) external returns (uint32 exitCode, bytes memory data) { + bytes memory callData = abi.encode(method, uint256(0), NO_FLAGS, uint64(0), params, REWARD_ACTOR_ID); + (bool success, bytes memory ret) = CALL_ACTOR_BY_ID.delegatecall(callData); + require(success, "RewardCaller: precompile call failed"); + (exitCode,, data) = abi.decode(ret, (uint32, uint64, bytes)); + } +} + +contract FVMRewardActorTest is MockRewardTest { + using FVMPay for uint64; + + RewardCaller swaCaller; + RewardCaller writerCaller; + RewardCaller otherWriterCaller; + RewardCaller randomCaller; + + uint64 constant SERVICE_ID = 1; + uint64 constant CONSENSUS_ID = 2; + + function setUp() public override { + super.setUp(); + swaCaller = new RewardCaller(); + writerCaller = new RewardCaller(); + otherWriterCaller = new RewardCaller(); + randomCaller = new RewardCaller(); + rewardActor().mockSwa(address(swaCaller)); + } + + // ------------------------------------------------------------------------- + // Call helpers + // ------------------------------------------------------------------------- + + function _call(uint64 method, bytes memory params) internal returns (uint32 exitCode, bytes memory data) { + bytes memory callData = abi.encode(method, uint256(0), NO_FLAGS, uint64(0), params, REWARD_ACTOR_ID); + (bool success, bytes memory ret) = CALL_ACTOR_BY_ID.delegatecall(callData); + assertTrue(success, "precompile call failed"); + (exitCode,, data) = abi.decode(ret, (uint32, uint64, bytes)); + } + + 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}); + } + + /// @dev A record whose weight is the constant `w` at every epoch. + function _constantRecord(int256 w) internal pure returns (WeightRecord memory) { + return _record(w, 0, 0, 0, WAD); + } + + function _registerParams(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) + internal + view + returns (bytes memory) + { + return abi.encode(id, record, kind, writer, uint64(block.number) + SWA_TIMELOCK); + } + + function _registerStream(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) + internal + returns (uint32) + { + (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, _registerParams(id, record, kind, writer)); + return exitCode; + } + + function _warpPastTimelockAndSettle() internal { + vm.roll(block.number + SWA_TIMELOCK); + _call(GET_STATE, ""); // any dispatched call settles pending writes + } + + function _getState() + internal + returns ( + uint64[] memory ids, + WeightRecord[] memory records, + DistributionKind[] memory kinds, + address[] memory writers, + int256[] memory weights + ) + { + (uint32 exitCode, bytes memory data) = _call(GET_STATE, ""); + assertEq(exitCode, 0); + (ids, records, kinds, writers, weights) = + abi.decode(data, (uint64[], WeightRecord[], DistributionKind[], address[], int256[])); + } + + // ------------------------------------------------------------------------- + // SWA_TIMELOCK + // ------------------------------------------------------------------------- + + // FIP-0118 section 2.4/4: the objection window is 7 days; epochs are 30s. + function test_SwaTimelock_IsSevenDaysOfEpochs() public pure { + assertEq(SWA_TIMELOCK, 7 * 24 * 60 * 60 / 30); + } + + // ------------------------------------------------------------------------- + // ComputeWeight -- clamp(v_start + slope * (e - t_start), floor, cap) + // ------------------------------------------------------------------------- + + function _computeWeight(WeightRecord memory record, uint64 epoch) internal returns (int256) { + (uint32 exitCode, bytes memory data) = _call(COMPUTE_WEIGHT, abi.encode(record, epoch)); + assertEq(exitCode, 0); + return abi.decode(data, (int256)); + } + + function test_ComputeWeight_AtTStart_ReturnsVStart() public { + WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); + assertEq(_computeWeight(r, 1000), 0.95e18); + } + + function test_ComputeWeight_MidRamp_IsLinear() public { + WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); + // 500 epochs after t_start: 0.95e18 - 100*500 = 0.95e18 - 50000 + assertEq(_computeWeight(r, 1500), 0.95e18 - 50_000); + } + + function test_ComputeWeight_PastFloor_ClampsToFloor() public { + WeightRecord memory r = _record(0.95e18, -1e17, 1000, 0.5e18, 0.95e18); + // 10 epochs after t_start at slope -1e17/epoch is already far past the 0.5e18 floor. + assertEq(_computeWeight(r, 1010), 0.5e18); + } + + function test_ComputeWeight_BeforeTStart_ClampsToCap() public { + // e < t_start with a negative slope means (e - t_start) < 0, so raw > v_start = cap. + WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); + assertEq(_computeWeight(r, 0), 0.95e18); + } + + function test_ComputeWeight_ZeroSlope_IsConstant() public { + WeightRecord memory r = _constantRecord(0.3e18); + assertEq(_computeWeight(r, 0), 0.3e18); + assertEq(_computeWeight(r, 1_000_000), 0.3e18); + } + + // ------------------------------------------------------------------------- + // GetState + // ------------------------------------------------------------------------- + + function test_GetState_Empty_ReturnsNoStreams() public { + (uint64[] memory ids,,,,) = _getState(); + assertEq(ids.length, 0); + } + + function test_GetState_ReflectsRegisteredStream_WithMatchingWeight() public { + WeightRecord memory r = _constantRecord(0.4e18); + assertEq(_registerStream(SERVICE_ID, r, DistributionKind.EXPLICIT, address(writerCaller)), 0); + _warpPastTimelockAndSettle(); + + ( + uint64[] memory ids, + WeightRecord[] memory records, + DistributionKind[] memory kinds, + address[] memory writers, + int256[] memory weights + ) = _getState(); + assertEq(ids.length, 1); + assertEq(ids[0], SERVICE_ID); + assertEq(records[0].vStart, r.vStart); + assertEq(uint8(kinds[0]), uint8(DistributionKind.EXPLICIT)); + assertEq(writers[0], address(writerCaller)); + assertEq(weights[0], _computeWeight(r, uint64(block.number))); + } + + // ------------------------------------------------------------------------- + // RegisterStream + // ------------------------------------------------------------------------- + + function test_RegisterStream_NotSwa_Forbidden() public { + (uint32 exitCode,) = randomCaller.call( + REGISTER_STREAM, _registerParams(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)) + ); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_RegisterStream_ActivationTooSoon_IllegalArgument() public { + bytes memory params = abi.encode( + SERVICE_ID, + _constantRecord(0.1e18), + DistributionKind.IMPLICIT, + address(0), + uint64(block.number) + SWA_TIMELOCK - 1 + ); + (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, params); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_RegisterStream_FloorAboveCap_IllegalArgument() public { + WeightRecord memory r = _record(0.1e18, 0, 0, 0.6e18, 0.5e18); + assertEq(_registerStream(SERVICE_ID, r, DistributionKind.IMPLICIT, address(0)), USR_ILLEGAL_ARGUMENT); + } + + function test_RegisterStream_CapAboveOne_IllegalArgument() public { + WeightRecord memory r = _record(0.1e18, 0, 0, 0, WAD + 1); + assertEq(_registerStream(SERVICE_ID, r, DistributionKind.IMPLICIT, address(0)), USR_ILLEGAL_ARGUMENT); + } + + function test_RegisterStream_FloorBelowZero_IllegalArgument() public { + WeightRecord memory r = _record(0.1e18, 0, 0, -1, WAD); + assertEq(_registerStream(SERVICE_ID, r, DistributionKind.IMPLICIT, address(0)), USR_ILLEGAL_ARGUMENT); + } + + function test_RegisterStream_ImplicitWithWriter_IllegalArgument() public { + assertEq( + _registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(writerCaller)), + USR_ILLEGAL_ARGUMENT + ); + } + + function test_RegisterStream_ExplicitWithoutWriter_IllegalArgument() public { + assertEq( + _registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.EXPLICIT, address(0)), + USR_ILLEGAL_ARGUMENT + ); + } + + function test_RegisterStream_DuplicateId_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + assertEq( + _registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), + USR_ILLEGAL_ARGUMENT + ); + } + + function test_RegisterStream_AtMaxStreams_IllegalArgument() public { + for (uint64 i = 0; i < MAX_STREAMS; i++) { + assertEq(_registerStream(i, _constantRecord(0), DistributionKind.IMPLICIT, address(0)), 0); + } + uint64 oneMoreId = uint64(MAX_STREAMS); + assertEq( + _registerStream(oneMoreId, _constantRecord(0), DistributionKind.IMPLICIT, address(0)), USR_ILLEGAL_ARGUMENT + ); + } + + function test_RegisterStream_SumWouldExceedOne_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.6e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + assertEq( + _registerStream(CONSENSUS_ID, _constantRecord(0.5e18), DistributionKind.IMPLICIT, address(0)), + USR_ILLEGAL_ARGUMENT + ); + } + + function test_RegisterStream_PendingUntilActivation() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + (uint64[] memory ids,,,,) = _getState(); + assertEq(ids.length, 0, "stream must not be live before its activation epoch"); + + _warpPastTimelockAndSettle(); + (uint64[] memory idsAfter,,,,) = _getState(); + assertEq(idsAfter.length, 1); + assertEq(idsAfter[0], SERVICE_ID); + } + + // ------------------------------------------------------------------------- + // SetWeightRecords + // ------------------------------------------------------------------------- + + function _setWeightParams(uint64 id, WeightRecord memory record) internal pure returns (bytes memory) { + uint64[] memory ids = new uint64[](1); + ids[0] = id; + WeightRecord[] memory records = new WeightRecord[](1); + records[0] = record; + return abi.encode(ids, records); + } + + function test_SetWeightRecords_NotSwa_Forbidden() public { + (uint32 exitCode,) = + randomCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_SetWeightRecords_NonexistentStream_NotFound() public { + (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + assertEq(exitCode, USR_NOT_FOUND); + } + + function test_SetWeightRecords_MismatchedArrayLengths_IllegalArgument() public { + uint64[] memory ids = new uint64[](2); + WeightRecord[] memory records = new WeightRecord[](1); + (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, abi.encode(ids, records)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetWeightRecords_InsaneRecord_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + WeightRecord memory bad = _record(0, 0, 0, 0.9e18, 0.1e18); // floor > cap + (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, bad)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetWeightRecords_SumWouldExceedOne_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.3e18), DistributionKind.IMPLICIT, address(0)), 0); + assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.3e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.8e18))); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetWeightRecords_QueuedUntilTimelockElapses() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.7e18))); + assertEq(setExit, 0); + + (, WeightRecord[] memory recordsBefore,,,) = _getState(); + assertEq(recordsBefore[0].vStart, 0.1e18, "must not apply before the timelock elapses"); + + _warpPastTimelockAndSettle(); + (, WeightRecord[] memory recordsAfter,,,) = _getState(); + assertEq(recordsAfter[0].vStart, 0.7e18); + } + + // ------------------------------------------------------------------------- + // SetShares + // ------------------------------------------------------------------------- + + function _shares(address wallet, uint256 amount) internal pure returns (Share[] memory arr) { + arr = new Share[](1); + arr[0] = Share({wallet: wallet, share: amount}); + } + + function _setSharesParams(uint64 id, Share[] memory shares_) internal pure returns (bytes memory) { + return abi.encode(id, shares_); + } + + function _registerExplicit(uint64 id, address writer) internal { + assertEq(_registerStream(id, _constantRecord(0.1e18), DistributionKind.EXPLICIT, writer), 0); + _warpPastTimelockAndSettle(); + } + + function test_SetShares_NotWriter_Forbidden() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 exitCode,) = + randomCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_SetShares_NonexistentStream_NotFound() public { + (uint32 exitCode,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + assertEq(exitCode, USR_NOT_FOUND); + } + + function test_SetShares_ImplicitStream_IllegalArgument() public { + assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + (uint32 exitCode,) = + randomCaller.call(SET_SHARES, _setSharesParams(CONSENSUS_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetShares_DoesNotSumToOne_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 under,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL - 1))); + assertEq(under, USR_ILLEGAL_ARGUMENT); + (uint32 over,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL + 1))); + assertEq(over, USR_ILLEGAL_ARGUMENT); + } + + function test_SetShares_TooManyRecipients_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + uint256 n = MAX_RECIPIENTS + 1; + Share[] memory shares_ = new Share[](n); + for (uint256 i = 0; i < n; i++) { + shares_[i] = Share({wallet: address(uint160(i + 1)), share: SHARE_TOTAL / n}); + } + (uint32 exitCode,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, shares_)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetShares_Valid_AppliesImmediately_NoTimelock() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + Share[] memory shares_ = _shares(address(0xBEEF), SHARE_TOTAL); + (uint32 exitCode,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, shares_)); + assertEq(exitCode, 0); + + // No vm.roll here: SetShares is the writer's own write, not queued under SWA_TIMELOCK. + Share[] memory got = rewardActor().getShares(SERVICE_ID); + assertEq(got.length, 1); + assertEq(got[0].wallet, address(0xBEEF)); + assertEq(got[0].share, SHARE_TOTAL); + } + + // ------------------------------------------------------------------------- + // RemoveStream + // ------------------------------------------------------------------------- + + function test_RemoveStream_NotSwa_Forbidden() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 exitCode,) = randomCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_RemoveStream_Nonexistent_NotFound() public { + (uint32 exitCode,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(exitCode, USR_NOT_FOUND); + } + + function test_RemoveStream_QueuedThenRemoved_AndSharesCleared() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + assertEq(setExit, 0); + + (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(removeExit, 0); + + (uint64[] memory idsBefore,,,,) = _getState(); + assertEq(idsBefore.length, 1, "must still be live before the timelock elapses"); + + _warpPastTimelockAndSettle(); + (uint64[] memory idsAfter,,,,) = _getState(); + assertEq(idsAfter.length, 0); + assertEq(rewardActor().getShares(SERVICE_ID).length, 0); + } + + // ------------------------------------------------------------------------- + // SetDistribution + // ------------------------------------------------------------------------- + + function test_SetDistribution_NotSwa_Forbidden() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 exitCode,) = randomCaller.call( + SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) + ); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_SetDistribution_ToImplicit_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 exitCode,) = + swaCaller.call(SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.IMPLICIT, address(0))); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_SetDistribution_ZeroWriter_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 exitCode,) = + swaCaller.call(SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(0))); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + // FIP-0118 2.4 item 9: "The stream's current wallet-to-share map remains in force until + // the new writer overwrites it via SetShares, so payments continue across the transition." + function test_SetDistribution_OldWriterStaysAuthorizedUntilTimelockElapses() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 initialSet,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + assertEq(initialSet, 0); + + (uint32 distExit,) = swaCaller.call( + SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) + ); + assertEq(distExit, 0); + + // Before the timelock elapses: old writer still authorized, new writer is not, and + // the existing map (from initialSet) is untouched. + (uint32 oldWriterExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xCAFE), SHARE_TOTAL))); + assertEq(oldWriterExit, 0); + (uint32 newWriterExitTooEarly,) = + otherWriterCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + assertEq(newWriterExitTooEarly, USR_FORBIDDEN); + + _warpPastTimelockAndSettle(); + + // After the timelock elapses: roles have swapped. + (uint32 oldWriterExitTooLate,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + assertEq(oldWriterExitTooLate, USR_FORBIDDEN); + (uint32 newWriterExit,) = + otherWriterCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + assertEq(newWriterExit, 0); + assertEq(rewardActor().getShares(SERVICE_ID)[0].wallet, address(0xF00D)); + } + + // ------------------------------------------------------------------------- + // CancelPending + // ------------------------------------------------------------------------- + + function test_CancelPending_NotSwa_Forbidden() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); + assertEq(setExit, 0); + (uint32 exitCode,) = randomCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_CancelPending_NoPending_NotFound() public { + (uint32 exitCode,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + assertEq(exitCode, USR_NOT_FOUND); + } + + function test_CancelPending_DiscardsQueuedSetWeightRecords() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); + assertEq(setExit, 0); + + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + (, WeightRecord[] memory records,,,) = _getState(); + assertEq(records[0].vStart, 0.1e18, "cancelled write must never apply"); + } + + function test_CancelPending_DiscardsQueuedRegisterStream() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + (uint64[] memory ids,,,,) = _getState(); + assertEq(ids.length, 0, "cancelled registration must never take effect"); + } + + function test_CancelPending_DiscardsQueuedRemoveStream() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(removeExit, 0); + + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + (uint64[] memory ids,,,,) = _getState(); + assertEq(ids.length, 1, "cancelled removal must never take effect"); + } + + // ------------------------------------------------------------------------- + // Fidelity: native-actor fallback and precompile guard + // ------------------------------------------------------------------------- + + // The real reward actor is a native actor: direct EVM CALL (InvokeContract) fails with + // USR_UNHANDLED_MESSAGE rather than succeeding like an account actor would. + function test_DirectEvmCall_ReturnsUnhandledMessage() public { + (bool ok, bytes memory ret) = REWARD_ACTOR_ADDRESS.call(""); + assertTrue(ok); + (uint32 exitCode,,) = abi.decode(ret, (uint32, uint64, bytes)); + assertEq(exitCode, USR_UNHANDLED_MESSAGE); + } + + // The real precompile requires delegatecall; a direct call/staticcall must fail. + function test_DirectCallToPrecompile_Reverts() public { + (bool ok,) = CALL_ACTOR_BY_ID.call(""); + assertFalse(ok); + } + + // ------------------------------------------------------------------------- + // Regression: FVMCallActorByIdWithReward must not break existing actor mocks + // ------------------------------------------------------------------------- + + // Exercises _handleBurn's `address(this).balance` debit through the new dispatcher -- + // the specific behavior a `call`-based (rather than `delegatecall`-based) forward to the + // underlying FVMCallActorById would have broken. + function test_Regression_Burn_StillDebitsCallerBalance() public { + uint256 before = address(this).balance; + assertEq(BURN_ADDRESS.balance, 0); + BURN_ACTOR_ID.pay(10 ether); + assertEq(BURN_ADDRESS.balance, 10 ether); + assertEq(address(this).balance, before - 10 ether); + } + + // Exercises the storage power actor branch (_handlePower) through the new dispatcher. + function test_Regression_MinerPower_StillWorks() public { + mockMiner(555); + assertTrue(FVMMiner.isMiner(555)); + assertFalse(FVMMiner.isMiner(556)); + } +} diff --git a/test/mocks/FVMRewardMethod.sol b/test/mocks/FVMRewardMethod.sol new file mode 100644 index 0000000..e08937b --- /dev/null +++ b/test/mocks/FVMRewardMethod.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +// FRC-0042 method numbers (first 4 bytes of blake2b-512("1|" + MethodName), rejection-sampled +// above 1<<24) for the new f02 (Reward actor) methods proposed by draft FIP-0118 +// (https://github.com/filecoin-project/FIPs/pull/1270) and tracked in +// https://github.com/filecoin-project/builtin-actors/issues/1764. Not yet implemented in +// builtin-actors; defined here so solstice's contracts and this mock agree on the same +// method numbers ahead of the real actor shipping them. +uint64 constant SET_WEIGHT_RECORDS = 3362570548; +uint64 constant SET_SHARES = 2414422607; +uint64 constant GET_STATE = 1397113977; +uint64 constant REGISTER_STREAM = 386660827; +uint64 constant REMOVE_STREAM = 1623858416; +uint64 constant SET_DISTRIBUTION = 3872725033; +uint64 constant CANCEL_PENDING = 187585191; +uint64 constant COMPUTE_WEIGHT = 2393050123; + +/// @dev FIP-0118 section 2.4: the activation timelock the SWA's writes to f02 are queued +/// under, equal to the Section 4 objection window (7 days), in epochs (30s/epoch). +uint64 constant SWA_TIMELOCK = 20160; diff --git a/test/mocks/MockRewardTest.sol b/test/mocks/MockRewardTest.sol new file mode 100644 index 0000000..4865adf --- /dev/null +++ b/test/mocks/MockRewardTest.sol @@ -0,0 +1,27 @@ +// 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 {FVMRewardActor} from "./FVMRewardActor.sol"; + +/// @notice Extends fvm-solidity's MockFVMTest with a mock f02 (Reward actor) covering the +/// stream-splitting methods proposed in FIP-0118 +/// (https://github.com/filecoin-project/FIPs/pull/1270, tracked in +/// https://github.com/filecoin-project/builtin-actors/issues/1764). +contract MockRewardTest is MockFVMTest { + function setUp() public virtual override { + super.setUp(); + vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardActor()).code); + vm.etch( + CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm, FVMRewardActor(REWARD_ACTOR_ADDRESS))).code + ); + } + + function rewardActor() internal pure returns (FVMRewardActor) { + return FVMRewardActor(REWARD_ACTOR_ADDRESS); + } +} From 0c0d86f0aa5921154c8032ee592301215e0b66ca Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 14:11:36 -0500 Subject: [PATCH 02/14] feat(f02): rework reward-actor mock for pull-settlement design Assisted-by: Claude:claude-sonnet-4-6 --- test/mocks/FVMRewardActor.sol | 631 ++++++++++++++++++++++++-------- test/mocks/FVMRewardActor.t.sol | 567 +++++++++++++++++++++------- test/mocks/FVMRewardMethod.sol | 15 +- test/mocks/MockRewardTest.sol | 7 +- 4 files changed, 920 insertions(+), 300 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 740cf87..2e148ff 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -2,33 +2,32 @@ pragma solidity ^0.8.36; import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol"; +import {FVMPay} from "fvm-solidity/FVMPay.sol"; import { SET_WEIGHT_RECORDS, + STEP_WEIGHT_RECORDS, SET_SHARES, GET_STATE, REGISTER_STREAM, REMOVE_STREAM, SET_DISTRIBUTION, CANCEL_PENDING, - COMPUTE_WEIGHT, + CLAIM, SWA_TIMELOCK } from "./FVMRewardMethod.sol"; /// @dev Weights, and per-orchestrator shares, are WAD-scaled: 1e18 == 1.0 == 100%. int256 constant WAD = 1e18; -/// @dev Mock-only caps; FIP-0118 requires MAX_STREAMS/MAX_RECIPIENTS limits exist but does -/// not fix their values, so these are placeholders sized for testing, not consensus values. +/// @dev Mock-only caps; f02 requires these limits to exist but never fixes their values. uint256 constant MAX_STREAMS = 8; -uint256 constant MAX_RECIPIENTS = 32; +uint256 constant MAX_RECIPIENTS = 64; -/// @dev Same value as WAD, typed uint256: shares (always non-negative) are stored unsigned, -/// so comparing their sum against this avoids a signed-to-unsigned cast of WAD. +/// @dev Same value as WAD, typed uint256, so summing shares needs no signed-to-unsigned cast. uint256 constant SHARE_TOTAL = 1e18; -/// @notice FIP-0118 section 2.4: the bundle of scheduler parameters for one stream's weight. -/// @dev vStart/slope/floor/cap are WAD-scaled Rationals; slope may be negative (w1's ramp). +/// @notice Per-stream weight schedule; WAD-scaled, slope may be negative. struct WeightRecord { int256 vStart; int256 slope; @@ -37,88 +36,207 @@ struct WeightRecord { int256 cap; } -/// @notice A stream's Distribution: IMPLICIT (consensus stream only, f02-resolved recipient) -/// or EXPLICIT (a wallet-to-share map maintained by the stream's designated writer). +/// @notice IMPLICIT is the consensus stream (f02-resolved); EXPLICIT uses a writer-owned share map. enum DistributionKind { IMPLICIT, EXPLICIT } -/// @notice One entry in an EXPLICIT stream's wallet-to-share map. struct Share { address wallet; uint256 share; } -/// @notice FIP-0118 section 2.4: `Stream = { id, WeightRecord, Distribution }`. `id` is the -/// mapping key rather than a field here; `shares` is the EXPLICIT distribution's -/// wallet-to-share map (unused when `kind == IMPLICIT`). +struct LedgerRow { + address wallet; + uint256 amount; +} + +/// @notice The five queueable SWA write kinds; SetShares and Claim never queue. +enum PendingOp { + SET_WEIGHT, + STEP_WEIGHT, + REGISTER, + REMOVE, + SET_DISTRIBUTION +} + +/// @dev Enumerable, prunable address->uint256 balance -- plain mappings-plus-array, not the +/// builtin-actor's CBOR-behind-a-CID shape (fine: nothing implements that wire format yet). +struct Ledger { + mapping(address => uint256) amount; + mapping(address => uint256) indexPlusOne; // 0 == not tracked + address[] wallets; +} + +/// @notice A registered stream (`id` is the mapping key) plus its per-stream ledgers; +/// `shares`/`writer`/`accrued`/the ledgers are unused for IMPLICIT streams. struct Stream { bool exists; WeightRecord weightRecord; DistributionKind kind; address writer; Share[] shares; + uint256 accrued; + Ledger payableLedger; + Ledger claimedPeriod; } -enum PendingKind { - NONE, - REGISTER, - SET_WEIGHT, - SET_DISTRIBUTION, - REMOVE +/// @notice A removed stream's outstanding liabilities; a drained tombstone deletes itself. +struct Tombstone { + bool exists; + Ledger payableLedger; } -/// @dev A queued SWA write, held until `effectiveEpoch` per SWA_TIMELOCK. Only one pending -/// write per stream id at a time -- a second SWA write for the same id simply replaces it, -/// matching CancelPending's premise that there is a single queued write to discard. +/// @dev A queued SWA write. Keyed by (streamId, op); an occupied slot rejects, so revising a +/// pending write means cancel + requeue. struct Pending { - PendingKind kind; + uint64 effectiveEpoch; + WeightRecord weightRecord; // SET_WEIGHT / STEP_WEIGHT / REGISTER payload + DistributionKind distributionKind; // REGISTER / SET_DISTRIBUTION payload + address writer; // REGISTER / SET_DISTRIBUTION payload +} + +struct PendingKey { + uint64 id; + PendingOp op; +} + +struct StreamView { + uint64 id; + WeightRecord weightRecord; + int256 weight; // clamped weight at the current epoch + DistributionKind kind; + address writer; + uint256 accrued; + Share[] shares; + LedgerRow[] payableRows; + LedgerRow[] claimedPeriodRows; +} + +struct TombstoneView { + uint64 id; + LedgerRow[] payableRows; +} + +struct PendingView { + uint64 id; + PendingOp op; uint64 effectiveEpoch; WeightRecord weightRecord; DistributionKind distributionKind; address writer; } -/// @notice Mock for the Filecoin Reward actor (f02) covering the stream-splitting methods -/// proposed by draft FIP-0118 (https://github.com/filecoin-project/FIPs/pull/1270, -/// tracked in https://github.com/filecoin-project/builtin-actors/issues/1764): -/// SetWeightRecords, SetShares, GetState, RegisterStream, RemoveStream, -/// SetDistribution, CancelPending, and ComputeWeight. -/// @dev These mocks live in solstice, not fvm-solidity, because they mock methods that don't -/// exist in builtin-actors yet and will only ever be called by solstice's own contracts -/// (the future Stream Weights Actor and Service Rewards Actor). -/// @dev Etch this at REWARD_ACTOR_ADDRESS via MockRewardTest, which also re-etches -/// CALL_ACTOR_BY_ID with FVMCallActorByIdWithReward so that -/// CALL_ACTOR_BY_ID(actorId=REWARD_ACTOR_ID) reaches handle_filecoin_method below. -/// @dev Params/returns use plain abi.encode/abi.decode rather than CBOR: the real wire format -/// doesn't exist yet since builtin-actors hasn't implemented these methods, so there is -/// nothing to match. Revisit the encoding once the real actor ships. +/// @notice Mock for the Filecoin Reward actor (f02), covering its stream-splitting methods. +/// @dev Etch at REWARD_ACTOR_ADDRESS via MockRewardTest, which also re-etches CALL_ACTOR_BY_ID +/// to reach handle_filecoin_method below. +/// @dev GetState persists due writes rather than only projecting them; behaviorally identical +/// once `effectiveEpoch` has passed. contract FVMRewardActor { - /// @notice The address authorized to call the SWA-only methods (SetWeightRecords, - /// RegisterStream, RemoveStream, SetDistribution, CancelPending). + /// @notice Address authorized to call the SWA-only methods. address public swa; + /// @notice Per-network SWA write hold, in epochs; mutable via mockSwaTimelockEpochs. + /// @dev Left uninitialized inline (vm.etch copies bytecode, not storage -- an inline + /// initializer would never apply); mockInit() sets it after etching. + uint64 public swaTimelockEpochs; + + /// @notice Cumulative FIL minted through f02, all streams (T = position 9 / FilMined). + uint256 public totalMintedReward; + /// @notice Cumulative burn: w0 residual plus period-fold rounding dust (B). + uint256 public totalBurnMinted; + /// @notice Cumulative gross accrual to EXPLICIT streams (S); miner's share T-B-S is derived, never stored. + uint256 public totalServiceMinted; + + /// @notice Minimum effectiveEpoch over pending writes; type(uint64).max sentinel when empty. + uint64 public nextTransitionEpoch; + mapping(uint64 streamId => Stream) internal _streams; uint64[] internal _streamIds; - mapping(uint64 streamId => Pending) internal _pending; - uint64[] internal _pendingIds; + mapping(uint64 streamId => Tombstone) internal _tombstones; + uint64[] internal _tombstoneIds; + + mapping(uint64 streamId => mapping(PendingOp => Pending)) internal _pending; + mapping(uint64 streamId => mapping(PendingOp => bool)) internal _pendingExists; + PendingKey[] internal _pendingKeys; + + event Claimed(uint64 indexed streamId, address indexed wallet, uint256 amount); + /// @dev Fires only when an occupied slot is actually removed; cancelling an empty slot is a no-op. + event PendingCancelled(uint64 indexed streamId, PendingOp op); + event BlockRewardAwarded(uint256 br, uint256 minerPortion, uint256 servicePortion, uint256 burnAmount); + + /// @notice Test helper: sets the defaults an inline initializer would give this contract; call once, right after etching. + function mockInit() external { + swaTimelockEpochs = SWA_TIMELOCK; + nextTransitionEpoch = type(uint64).max; + } /// @notice Test helper: set the address authorized to call SWA-only methods. function mockSwa(address swa_) external { swa = swa_; } - /// @notice Test helper: read back an EXPLICIT stream's wallet-to-share map directly, - /// without going through the CALL_ACTOR_BY_ID/CBOR-shaped GetState round trip. + function mockSwaTimelockEpochs(uint64 epochs) external { + swaTimelockEpochs = epochs; + } + + /// @notice Test helper: simulates AwardBlockReward, splitting `br` by clamped weight into a + /// miner portion (IMPLICIT; the actual payout is the unmocked ApplyRewards path), a service + /// portion (EXPLICIT, accrues for Claim/SetShares), and a burn residual. + /// @dev Caller must vm.deal this contract's balance up by `br` first -- a block reward is + /// newly minted, not moved from an existing balance. + function mockAwardBlockReward(uint256 br) + external + returns (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) + { + _settle(); + uint64 nowEpoch = uint64(block.number); + for (uint256 i = 0; i < _streamIds.length; i++) { + uint64 id = _streamIds[i]; + Stream storage s = _streams[id]; + int256 w = _clampWeight(s.weightRecord, nowEpoch); + uint256 amount = (uint256(w) * br) / uint256(WAD); + if (s.kind == DistributionKind.IMPLICIT) { + minerPortion += amount; + } else { + servicePortion += amount; + s.accrued += amount; + } + } + burnAmount = br - minerPortion - servicePortion; + + totalMintedReward += br; + totalBurnMinted += burnAmount; + totalServiceMinted += servicePortion; + + if (burnAmount > 0) FVMPay.burn(burnAmount); + emit BlockRewardAwarded(br, minerPortion, servicePortion, burnAmount); + } + + /// @notice Test helper: an EXPLICIT stream's wallet-to-share map, without the GetState round trip. function getShares(uint64 streamId) external view returns (Share[] memory) { return _streams[streamId].shares; } - /// @notice Fallback for unknown ABI selectors, matching the real reward actor's behavior - /// for direct EVM CALL (InvokeContract): it's a native actor, so this returns - /// USR_UNHANDLED_MESSAGE rather than reverting. + /// @notice Test helper: read back a live stream's payable ledger directly. + function getPayable(uint64 streamId) external view returns (LedgerRow[] memory) { + return _ledgerView(_streams[streamId].payableLedger); + } + + /// @notice Test helper: read back a tombstone's payable ledger directly. + function getTombstonePayable(uint64 streamId) external view returns (LedgerRow[] memory) { + return _ledgerView(_tombstones[streamId].payableLedger); + } + + /// @notice Test helper: the clamp(v_start + slope*(e-t_start), floor, cap) math, exposed + /// directly since it isn't a dispatched method (GetState already projects each weight). + function clampWeight(WeightRecord memory record, uint64 epoch) external pure returns (int256) { + return _clampWeight(record, epoch); + } + + /// @notice A native actor: direct EVM CALL returns USR_UNHANDLED_MESSAGE rather than reverting. fallback() external { bytes memory response = abi.encode(uint32(USR_UNHANDLED_MESSAGE), uint64(0), bytes("")); assembly ("memory-safe") { @@ -126,42 +244,42 @@ contract FVMRewardActor { } } - /// @dev Receives calls routed from FVMCallActorByIdWithReward's REWARD_ACTOR_ID branch. - /// Returns (exitCode, codec, data); never reverts for actor-level errors, matching real - /// FVM behavior where CALL_ACTOR_BY_ID returns success=true with a non-zero exit code. + /// @dev Routed here from FVMCallActorByIdWithReward's REWARD_ACTOR_ID branch. Never reverts + /// for actor-level errors -- returns a non-zero exit code instead, per CALL_ACTOR_BY_ID. // forge-lint: disable-next-line(mixed-case-function) function handle_filecoin_method(uint64 method, uint64, bytes calldata params) external returns (uint32, uint64, bytes memory) { _settle(); - if (method == SET_WEIGHT_RECORDS) return _setWeightRecords(params); + if (method == SET_WEIGHT_RECORDS) return _queueWeightWrite(PendingOp.SET_WEIGHT, params); + if (method == STEP_WEIGHT_RECORDS) return _queueWeightWrite(PendingOp.STEP_WEIGHT, params); if (method == SET_SHARES) return _setShares(params); if (method == GET_STATE) return _getState(); if (method == REGISTER_STREAM) return _registerStream(params); if (method == REMOVE_STREAM) return _removeStream(params); if (method == SET_DISTRIBUTION) return _setDistribution(params); if (method == CANCEL_PENDING) return _cancelPending(params); - if (method == COMPUTE_WEIGHT) return _computeWeight(params); + if (method == CLAIM) return _claim(params); return (USR_UNHANDLED_MESSAGE, 0, ""); } // ------------------------------------------------------------------------- - // SetWeightRecords(ids, records) -- SWA only, queued under SWA_TIMELOCK + // SetWeightRecords / StepWeightRecords -- SWA only, queued under separate ops. // ------------------------------------------------------------------------- - function _setWeightRecords(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + function _queueWeightWrite(PendingOp op, bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); (uint64[] memory ids, WeightRecord[] memory records) = abi.decode(params, (uint64[], WeightRecord[])); if (ids.length != records.length) return (USR_ILLEGAL_ARGUMENT, 0, ""); - uint64 effectiveEpoch = uint64(block.number) + SWA_TIMELOCK; + uint64 effectiveEpoch = uint64(block.number) + swaTimelockEpochs; for (uint256 i = 0; i < ids.length; i++) { if (!_streams[ids[i]].exists) return (USR_NOT_FOUND, 0, ""); if (!_sane(records[i])) return (USR_ILLEGAL_ARGUMENT, 0, ""); + if (_pendingExists[ids[i]][op]) return (USR_ILLEGAL_ARGUMENT, 0, ""); } - // Guardrail (FIP-0118 2.4): sum of all *other* streams' current weight, plus the - // newly proposed weights, must not exceed 1 at the activation epoch. + // Guardrail: sum of every stream's weight, including the proposed ones, must not exceed 1. int256 sum = _sumWeightsExcluding(ids, effectiveEpoch); for (uint256 i = 0; i < records.length; i++) { sum += _clampWeight(records[i], effectiveEpoch); @@ -169,10 +287,10 @@ contract FVMRewardActor { if (sum > WAD) return (USR_ILLEGAL_ARGUMENT, 0, ""); for (uint256 i = 0; i < ids.length; i++) { - _queue( + _queueWrite( ids[i], + op, Pending({ - kind: PendingKind.SET_WEIGHT, effectiveEpoch: effectiveEpoch, weightRecord: records[i], distributionKind: DistributionKind.IMPLICIT, @@ -184,70 +302,100 @@ contract FVMRewardActor { } // ------------------------------------------------------------------------- - // SetShares(id, shares) -- the stream's designated writer only, applied immediately + // SetShares -- designated writer only, applied immediately; folds the closing period into + // `payable` under the OLD map before installing the new one. // ------------------------------------------------------------------------- function _setShares(bytes calldata params) internal returns (uint32, uint64, bytes memory) { - (uint64 id, Share[] memory shares) = abi.decode(params, (uint64, Share[])); + (uint64 id, Share[] memory newShares) = abi.decode(params, (uint64, Share[])); Stream storage s = _streams[id]; if (!s.exists) return (USR_NOT_FOUND, 0, ""); if (s.kind != DistributionKind.EXPLICIT) return (USR_ILLEGAL_ARGUMENT, 0, ""); if (msg.sender != s.writer) return (USR_FORBIDDEN, 0, ""); - if (shares.length > MAX_RECIPIENTS) return (USR_ILLEGAL_ARGUMENT, 0, ""); + if (newShares.length > MAX_RECIPIENTS) return (USR_ILLEGAL_ARGUMENT, 0, ""); uint256 total; - for (uint256 i = 0; i < shares.length; i++) { - total += shares[i].share; + for (uint256 i = 0; i < newShares.length; i++) { + total += newShares[i].share; } if (total != SHARE_TOTAL) return (USR_ILLEGAL_ARGUMENT, 0, ""); + _foldAndBurnResidue(s); + delete s.shares; - for (uint256 i = 0; i < shares.length; i++) { - s.shares.push(shares[i]); + for (uint256 i = 0; i < newShares.length; i++) { + s.shares.push(newShares[i]); } return (0, 0, ""); } - // ------------------------------------------------------------------------- - // GetState() -- read-only - // ------------------------------------------------------------------------- - function _getState() internal view returns (uint32, uint64, bytes memory) { - uint256 n = _streamIds.length; - uint64[] memory ids = new uint64[](n); - WeightRecord[] memory records = new WeightRecord[](n); - DistributionKind[] memory kinds = new DistributionKind[](n); - address[] memory writers = new address[](n); - int256[] memory weights = new int256[](n); - uint64 nowEpoch = uint64(block.number); - for (uint256 i = 0; i < n; i++) { + + StreamView[] memory streams = new StreamView[](_streamIds.length); + for (uint256 i = 0; i < _streamIds.length; i++) { uint64 id = _streamIds[i]; Stream storage s = _streams[id]; - ids[i] = id; - records[i] = s.weightRecord; - kinds[i] = s.kind; - writers[i] = s.writer; - weights[i] = _clampWeight(s.weightRecord, nowEpoch); + streams[i] = StreamView({ + id: id, + weightRecord: s.weightRecord, + weight: _clampWeight(s.weightRecord, nowEpoch), + kind: s.kind, + writer: s.writer, + accrued: s.accrued, + shares: s.shares, + payableRows: _ledgerView(s.payableLedger), + claimedPeriodRows: _ledgerView(s.claimedPeriod) + }); } - return (0, 0, abi.encode(ids, records, kinds, writers, weights)); - } - // ------------------------------------------------------------------------- - // RegisterStream(id, weightRecord, kind, writer, activationEpoch) -- SWA only - // ------------------------------------------------------------------------- + TombstoneView[] memory tombstones = new TombstoneView[](_tombstoneIds.length); + for (uint256 i = 0; i < _tombstoneIds.length; i++) { + uint64 id = _tombstoneIds[i]; + tombstones[i] = TombstoneView({id: id, payableRows: _ledgerView(_tombstones[id].payableLedger)}); + } + + PendingView[] memory pendingWrites = new PendingView[](_pendingKeys.length); + for (uint256 i = 0; i < _pendingKeys.length; i++) { + PendingKey memory k = _pendingKeys[i]; + Pending storage p = _pending[k.id][k.op]; + pendingWrites[i] = PendingView({ + id: k.id, + op: k.op, + effectiveEpoch: p.effectiveEpoch, + weightRecord: p.weightRecord, + distributionKind: p.distributionKind, + writer: p.writer + }); + } + + return ( + 0, + 0, + abi.encode( + totalMintedReward, + totalBurnMinted, + totalServiceMinted, + nextTransitionEpoch, + swaTimelockEpochs, + streams, + tombstones, + pendingWrites + ) + ); + } function _registerStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); (uint64 id, WeightRecord memory record, DistributionKind kind, address writer, uint64 activationEpoch) = abi.decode(params, (uint64, WeightRecord, DistributionKind, address, uint64)); - if (_streams[id].exists || _pending[id].kind == PendingKind.REGISTER) { + // Rejects any id collision it can see: a live stream, an undrained tombstone, or an + // already-queued registration. Reuse after a tombstone fully drains is SWA discipline. + if (_streams[id].exists || _tombstones[id].exists || _pendingExists[id][PendingOp.REGISTER]) { return (USR_ILLEGAL_ARGUMENT, 0, ""); } - // Count queued-but-not-yet-settled registrations too: otherwise a burst of - // RegisterStream calls within one SWA_TIMELOCK window could blow past the cap, since - // none of them would be in _streamIds yet. + // Count queued registrations too, or a burst of calls could blow past the cap. if (_streamIds.length + _pendingRegistrationCount() >= MAX_STREAMS) { return (USR_ILLEGAL_ARGUMENT, 0, ""); } @@ -256,38 +404,34 @@ contract FVMRewardActor { if (kind == DistributionKind.IMPLICIT ? writer != address(0) : writer == address(0)) { return (USR_ILLEGAL_ARGUMENT, 0, ""); } - if (activationEpoch < uint64(block.number) + SWA_TIMELOCK) return (USR_ILLEGAL_ARGUMENT, 0, ""); + if (activationEpoch < uint64(block.number) + swaTimelockEpochs) return (USR_ILLEGAL_ARGUMENT, 0, ""); int256 sum = _sumWeightsExcluding(new uint64[](0), activationEpoch) + _clampWeight(record, activationEpoch); if (sum > WAD) return (USR_ILLEGAL_ARGUMENT, 0, ""); - _queue( + _queueWrite( id, - Pending({ - kind: PendingKind.REGISTER, - effectiveEpoch: activationEpoch, - weightRecord: record, - distributionKind: kind, - writer: writer - }) + PendingOp.REGISTER, + Pending({effectiveEpoch: activationEpoch, weightRecord: record, distributionKind: kind, writer: writer}) ); return (0, 0, ""); } // ------------------------------------------------------------------------- - // RemoveStream(id) -- SWA only, queued under SWA_TIMELOCK + // RemoveStream -- SWA only, queued; applying it folds the period then tombstones the rest. // ------------------------------------------------------------------------- function _removeStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); uint64 id = abi.decode(params, (uint64)); if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); + if (_pendingExists[id][PendingOp.REMOVE]) return (USR_ILLEGAL_ARGUMENT, 0, ""); - _queue( + _queueWrite( id, + PendingOp.REMOVE, Pending({ - kind: PendingKind.REMOVE, - effectiveEpoch: uint64(block.number) + SWA_TIMELOCK, + effectiveEpoch: uint64(block.number) + swaTimelockEpochs, weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), distributionKind: DistributionKind.IMPLICIT, writer: address(0) @@ -297,21 +441,22 @@ contract FVMRewardActor { } // ------------------------------------------------------------------------- - // SetDistribution(id, kind, writer) -- SWA only, queued under SWA_TIMELOCK + // SetDistribution -- SWA only, queued; changes only the writer, folding the period first. // ------------------------------------------------------------------------- function _setDistribution(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); (uint64 id, DistributionKind kind, address writer) = abi.decode(params, (uint64, DistributionKind, address)); if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); - // "Converting a stream to IMPLICIT is not permitted (IMPLICIT is consensus-only)." + // Converting a stream to IMPLICIT is not permitted (IMPLICIT is consensus-only). if (kind == DistributionKind.IMPLICIT || writer == address(0)) return (USR_ILLEGAL_ARGUMENT, 0, ""); + if (_pendingExists[id][PendingOp.SET_DISTRIBUTION]) return (USR_ILLEGAL_ARGUMENT, 0, ""); - _queue( + _queueWrite( id, + PendingOp.SET_DISTRIBUTION, Pending({ - kind: PendingKind.SET_DISTRIBUTION, - effectiveEpoch: uint64(block.number) + SWA_TIMELOCK, + effectiveEpoch: uint64(block.number) + swaTimelockEpochs, weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), distributionKind: kind, writer: writer @@ -321,33 +466,71 @@ contract FVMRewardActor { } // ------------------------------------------------------------------------- - // CancelPending(id) -- SWA only + // CancelPending -- SWA only; cancelling an empty slot is a benign no-op. // ------------------------------------------------------------------------- function _cancelPending(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); - uint64 id = abi.decode(params, (uint64)); - if (_pending[id].kind == PendingKind.NONE) return (USR_NOT_FOUND, 0, ""); - - delete _pending[id]; - _removePendingId(id); + (uint64 id, PendingOp op) = abi.decode(params, (uint64, PendingOp)); + if (_pendingExists[id][op]) { + delete _pending[id][op]; + _pendingExists[id][op] = false; + _removePendingKey(id, op); + _recomputeNextTransition(); + emit PendingCancelled(id, op); + } return (0, 0, ""); } // ------------------------------------------------------------------------- - // ComputeWeight(record, epoch) -- pure + // Claim -- permissionless, batched; zero-entitlement entries pay nothing, no revert. // ------------------------------------------------------------------------- - function _computeWeight(bytes calldata params) internal pure returns (uint32, uint64, bytes memory) { - (WeightRecord memory record, uint64 epoch) = abi.decode(params, (WeightRecord, uint64)); - return (0, 0, abi.encode(_clampWeight(record, epoch))); + function _claim(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + (uint64 id, address[] memory wallets) = abi.decode(params, (uint64, address[])); + + bool tombstoned = _tombstones[id].exists; + Stream storage s = _streams[id]; + if (!tombstoned) { + if (!s.exists) return (USR_NOT_FOUND, 0, ""); + if (s.kind != DistributionKind.EXPLICIT) return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + + uint256[] memory amounts = new uint256[](wallets.length); + for (uint256 i = 0; i < wallets.length; i++) { + address wallet = wallets[i]; + uint256 entitlement; + if (tombstoned) { + entitlement = _tombstones[id].payableLedger.amount[wallet]; + if (entitlement == 0) continue; + _ledgerRemove(_tombstones[id].payableLedger, wallet); + if (_tombstones[id].payableLedger.wallets.length == 0) { + _tombstones[id].exists = false; + _removeTombstoneId(id); + } + } else { + uint256 share = _shareOf(s, wallet); + uint256 claimed = s.claimedPeriod.amount[wallet]; + uint256 grossLive = (share * s.accrued) / SHARE_TOTAL; + uint256 live = grossLive > claimed ? grossLive - claimed : 0; + uint256 payableAmount = s.payableLedger.amount[wallet]; + entitlement = live + payableAmount; + if (entitlement == 0) continue; + if (live > 0) _ledgerIncrement(s.claimedPeriod, wallet, live); + if (payableAmount > 0) _ledgerRemove(s.payableLedger, wallet); + } + FVMPay.pay(wallet, entitlement); // method 0/SEND; cannot fail here + emit Claimed(id, wallet, entitlement); + amounts[i] = entitlement; + } + return (0, 0, abi.encode(amounts)); } // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- - /// @dev FIP-0118 2.4: `clamp(v_start + slope * (e - t_start), floor, cap)`. + /// @dev clamp(v_start + slope * (e - t_start), floor, cap). function _clampWeight(WeightRecord memory w, uint64 e) internal pure returns (int256 weight) { int256 raw = w.vStart + w.slope * (int256(uint256(e)) - int256(uint256(w.tStart))); weight = raw < w.floor ? w.floor : (raw > w.cap ? w.cap : raw); @@ -358,10 +541,8 @@ contract FVMRewardActor { return w.floor >= 0 && w.floor <= w.cap && w.cap <= WAD; } - /// @dev Sum of every *registered* stream's weight at `atEpoch`, excluding `excludeIds`. - /// Streams with a not-yet-settled pending write still count at their currently stored - /// (pre-write) record -- the invariant check is against what is live today, matching - /// f02's "validates ... at the activation epoch" rule for the write being proposed now. + /// @dev Sum of every registered stream's weight at `atEpoch`, excluding `excludeIds`; a + /// not-yet-settled pending write still counts at its currently stored record. function _sumWeightsExcluding(uint64[] memory excludeIds, uint64 atEpoch) internal view returns (int256 sum) { for (uint256 i = 0; i < _streamIds.length; i++) { uint64 id = _streamIds[i]; @@ -377,66 +558,192 @@ contract FVMRewardActor { } function _pendingRegistrationCount() internal view returns (uint256 count) { - for (uint256 i = 0; i < _pendingIds.length; i++) { - if (_pending[_pendingIds[i]].kind == PendingKind.REGISTER) count++; + for (uint256 i = 0; i < _pendingKeys.length; i++) { + if (_pendingKeys[i].op == PendingOp.REGISTER) count++; + } + } + + function _shareOf(Stream storage s, address wallet) internal view returns (uint256) { + for (uint256 i = 0; i < s.shares.length; i++) { + if (s.shares[i].wallet == wallet) return s.shares[i].share; + } + return 0; + } + + /// @dev Closes out the current period: each recipient's earned-minus-claimed amount moves + /// into `payable` under the OLD map, the rounding residue burns, and accrual state resets. + function _foldAndBurnResidue(Stream storage s) internal { + uint256 pool = s.accrued; + uint256 earnedSum; + for (uint256 i = 0; i < s.shares.length; i++) { + address wallet = s.shares[i].wallet; + uint256 earned = (s.shares[i].share * pool) / SHARE_TOTAL; + earnedSum += earned; + uint256 claimed = s.claimedPeriod.amount[wallet]; + if (earned > claimed) { + _ledgerIncrement(s.payableLedger, wallet, earned - claimed); + } + } + uint256 residue = pool - earnedSum; + s.accrued = 0; + _ledgerClearAll(s.claimedPeriod); + + if (residue > 0) { + FVMPay.burn(residue); + totalBurnMinted += residue; + } + } + + function _queueWrite(uint64 id, PendingOp op, Pending memory p) internal { + _pending[id][op] = p; + _pendingExists[id][op] = true; + _pendingKeys.push(PendingKey({id: id, op: op})); + if (p.effectiveEpoch < nextTransitionEpoch) nextTransitionEpoch = p.effectiveEpoch; + } + + function _removePendingKey(uint64 id, PendingOp op) internal { + for (uint256 i = 0; i < _pendingKeys.length; i++) { + if (_pendingKeys[i].id == id && _pendingKeys[i].op == op) { + _pendingKeys[i] = _pendingKeys[_pendingKeys.length - 1]; + _pendingKeys.pop(); + return; + } } } - function _queue(uint64 id, Pending memory p) internal { - if (_pending[id].kind == PendingKind.NONE) _pendingIds.push(id); - _pending[id] = p; + function _recomputeNextTransition() internal { + uint64 best = type(uint64).max; + for (uint256 i = 0; i < _pendingKeys.length; i++) { + uint64 e = _pending[_pendingKeys[i].id][_pendingKeys[i].op].effectiveEpoch; + if (e < best) best = e; + } + nextTransitionEpoch = best; } - function _removePendingId(uint64 id) internal { - for (uint256 i = 0; i < _pendingIds.length; i++) { - if (_pendingIds[i] == id) { - _pendingIds[i] = _pendingIds[_pendingIds.length - 1]; - _pendingIds.pop(); + function _removeTombstoneId(uint64 id) internal { + for (uint256 i = 0; i < _tombstoneIds.length; i++) { + if (_tombstoneIds[i] == id) { + _tombstoneIds[i] = _tombstoneIds[_tombstoneIds.length - 1]; + _tombstoneIds.pop(); return; } } } - /// @dev Applies every queued write whose effectiveEpoch has arrived. Called at the top - /// of every dispatched method so stored state is always current before it's read or - /// validated against. + function _ledgerIncrement(Ledger storage l, address wallet, uint256 delta) internal { + if (delta == 0) return; + if (l.indexPlusOne[wallet] == 0) { + l.wallets.push(wallet); + l.indexPlusOne[wallet] = l.wallets.length; + } + l.amount[wallet] += delta; + } + + function _ledgerRemove(Ledger storage l, address wallet) internal { + uint256 idx1 = l.indexPlusOne[wallet]; + if (idx1 == 0) return; + uint256 lastIdx = l.wallets.length - 1; + address lastWallet = l.wallets[lastIdx]; + l.wallets[idx1 - 1] = lastWallet; + l.indexPlusOne[lastWallet] = idx1; + l.wallets.pop(); + delete l.indexPlusOne[wallet]; + delete l.amount[wallet]; + } + + /// @dev Explicit teardown of both mappings: `delete` on a struct doesn't recurse into + /// mapping members, so a later id-reuse could otherwise resurface stale entries. + function _ledgerClearAll(Ledger storage l) internal { + uint256 n = l.wallets.length; + for (uint256 i = 0; i < n; i++) { + address wallet = l.wallets[i]; + delete l.amount[wallet]; + delete l.indexPlusOne[wallet]; + } + delete l.wallets; + } + + function _ledgerView(Ledger storage l) internal view returns (LedgerRow[] memory rows) { + rows = new LedgerRow[](l.wallets.length); + for (uint256 i = 0; i < l.wallets.length; i++) { + rows[i] = LedgerRow({wallet: l.wallets[i], amount: l.amount[l.wallets[i]]}); + } + } + + /// @dev Applies every queued write whose effectiveEpoch has arrived, in (effectiveEpoch, id) order. function _settle() internal { uint64 nowEpoch = uint64(block.number); - for (uint256 i = 0; i < _pendingIds.length;) { - uint64 id = _pendingIds[i]; - Pending storage p = _pending[id]; - if (nowEpoch >= p.effectiveEpoch) { - _apply(id, p); - delete _pending[id]; - _pendingIds[i] = _pendingIds[_pendingIds.length - 1]; - _pendingIds.pop(); - continue; + while (true) { + bool found = false; + uint256 bestIdx = 0; + uint64 bestEpoch = 0; + uint64 bestId = 0; + for (uint256 i = 0; i < _pendingKeys.length; i++) { + PendingKey memory k = _pendingKeys[i]; + uint64 e = _pending[k.id][k.op].effectiveEpoch; + if (e > nowEpoch) continue; + if (!found || e < bestEpoch || (e == bestEpoch && k.id < bestId)) { + found = true; + bestIdx = i; + bestEpoch = e; + bestId = k.id; + } } - i++; + if (!found) break; + + PendingKey memory key = _pendingKeys[bestIdx]; + _apply(key.id, key.op); + delete _pending[key.id][key.op]; + _pendingExists[key.id][key.op] = false; + _pendingKeys[bestIdx] = _pendingKeys[_pendingKeys.length - 1]; + _pendingKeys.pop(); } + _recomputeNextTransition(); } - function _apply(uint64 id, Pending storage p) internal { - if (p.kind == PendingKind.REGISTER) { + function _apply(uint64 id, PendingOp op) internal { + Pending storage p = _pending[id][op]; + if (op == PendingOp.REGISTER) { Stream storage s = _streams[id]; s.exists = true; s.weightRecord = p.weightRecord; s.kind = p.distributionKind; s.writer = p.writer; _streamIds.push(id); - } else if (p.kind == PendingKind.SET_WEIGHT) { + } else if (op == PendingOp.SET_WEIGHT || op == PendingOp.STEP_WEIGHT) { _streams[id].weightRecord = p.weightRecord; - } else if (p.kind == PendingKind.SET_DISTRIBUTION) { - _streams[id].kind = p.distributionKind; - _streams[id].writer = p.writer; - } else if (p.kind == PendingKind.REMOVE) { - delete _streams[id]; - for (uint256 j = 0; j < _streamIds.length; j++) { - if (_streamIds[j] == id) { - _streamIds[j] = _streamIds[_streamIds.length - 1]; - _streamIds.pop(); - break; - } + } else if (op == PendingOp.SET_DISTRIBUTION) { + Stream storage s = _streams[id]; + _foldAndBurnResidue(s); + s.kind = p.distributionKind; + s.writer = p.writer; + } else if (op == PendingOp.REMOVE) { + _applyRemove(id); + } + } + + function _applyRemove(uint64 id) internal { + Stream storage s = _streams[id]; + _foldAndBurnResidue(s); + + if (s.payableLedger.wallets.length > 0) { + Tombstone storage t = _tombstones[id]; + t.exists = true; + uint256 n = s.payableLedger.wallets.length; + for (uint256 i = 0; i < n; i++) { + address wallet = s.payableLedger.wallets[i]; + _ledgerIncrement(t.payableLedger, wallet, s.payableLedger.amount[wallet]); + } + _tombstoneIds.push(id); + } + _ledgerClearAll(s.payableLedger); + + delete _streams[id]; + for (uint256 i = 0; i < _streamIds.length; i++) { + if (_streamIds[i] == id) { + _streamIds[i] = _streamIds[_streamIds.length - 1]; + _streamIds.pop(); + break; } } } diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 8053ea7..7d56438 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -13,6 +13,11 @@ import { WeightRecord, DistributionKind, Share, + LedgerRow, + PendingOp, + StreamView, + TombstoneView, + PendingView, WAD, MAX_STREAMS, MAX_RECIPIENTS, @@ -20,19 +25,19 @@ import { } from "./FVMRewardActor.sol"; import { SET_WEIGHT_RECORDS, + STEP_WEIGHT_RECORDS, SET_SHARES, GET_STATE, REGISTER_STREAM, REMOVE_STREAM, SET_DISTRIBUTION, CANCEL_PENDING, - COMPUTE_WEIGHT, + CLAIM, SWA_TIMELOCK } from "./FVMRewardMethod.sol"; -/// @dev A distinct external caller, so a test can give SWA / stream-writer authorization to -/// an address other than the test contract itself and check that authorization is enforced -/// by identity, not by happenstance of who the test contract is. +/// @dev A distinct external caller, so tests can check authorization by identity rather than +/// by happenstance of who the test contract is. contract RewardCaller { function call(uint64 method, bytes memory params) external returns (uint32 exitCode, bytes memory data) { bytes memory callData = abi.encode(method, uint256(0), NO_FLAGS, uint64(0), params, REWARD_ACTOR_ID); @@ -53,6 +58,9 @@ contract FVMRewardActorTest is MockRewardTest { uint64 constant SERVICE_ID = 1; uint64 constant CONSENSUS_ID = 2; + address constant RECIPIENT_A = address(0xBEEF); + address constant RECIPIENT_B = address(0xCAFE); + function setUp() public override { super.setUp(); swaCaller = new RewardCaller(); @@ -102,82 +110,155 @@ contract FVMRewardActorTest is MockRewardTest { return exitCode; } + function _registerExplicit(uint64 id, address writer) internal { + assertEq(_registerStream(id, _constantRecord(0.1e18), DistributionKind.EXPLICIT, writer), 0); + _warpPastTimelockAndSettle(); + } + function _warpPastTimelockAndSettle() internal { vm.roll(block.number + SWA_TIMELOCK); _call(GET_STATE, ""); // any dispatched call settles pending writes } - function _getState() - internal - returns ( - uint64[] memory ids, - WeightRecord[] memory records, - DistributionKind[] memory kinds, - address[] memory writers, - int256[] memory weights - ) - { + /// @dev Bundled as a struct so call sites don't juggle an 8-way tuple. + struct GetStateResult { + uint256 totalMintedReward; + uint256 totalBurnMinted; + uint256 totalServiceMinted; + uint64 nextTransitionEpoch; + uint64 swaTimelockEpochs; + StreamView[] streams; + TombstoneView[] tombstones; + PendingView[] pendingWrites; + } + + function _getState() internal returns (GetStateResult memory r) { (uint32 exitCode, bytes memory data) = _call(GET_STATE, ""); assertEq(exitCode, 0); - (ids, records, kinds, writers, weights) = - abi.decode(data, (uint64[], WeightRecord[], DistributionKind[], address[], int256[])); + // Not `abi.decode(data, (GetStateResult))`: that single-struct-type decode sugar + // silently mis-decodes this array-of-structs-with-nested-arrays depth on solc 0.8.36 + // without via-ir. The tuple-typed decode below is correct, but must land in fresh + // locals -- assigning straight into named returns hits `stack too deep`. + ( + uint256 totalMintedReward, + uint256 totalBurnMinted, + uint256 totalServiceMinted, + uint64 nextTransitionEpoch, + uint64 swaTimelockEpochs, + StreamView[] memory streams, + TombstoneView[] memory tombstones, + PendingView[] memory pendingWrites + ) = abi.decode(data, (uint256, uint256, uint256, uint64, uint64, StreamView[], TombstoneView[], PendingView[])); + r = GetStateResult({ + totalMintedReward: totalMintedReward, + totalBurnMinted: totalBurnMinted, + totalServiceMinted: totalServiceMinted, + nextTransitionEpoch: nextTransitionEpoch, + swaTimelockEpochs: swaTimelockEpochs, + streams: streams, + tombstones: tombstones, + pendingWrites: pendingWrites + }); + } + + function _streams() internal returns (StreamView[] memory) { + return _getState().streams; } - // ------------------------------------------------------------------------- - // SWA_TIMELOCK - // ------------------------------------------------------------------------- + function _shares(address wallet, uint256 amount) internal pure returns (Share[] memory arr) { + arr = new Share[](1); + arr[0] = Share({wallet: wallet, share: amount}); + } + + function _setSharesParams(uint64 id, Share[] memory shares_) internal pure returns (bytes memory) { + return abi.encode(id, shares_); + } - // FIP-0118 section 2.4/4: the objection window is 7 days; epochs are 30s. + function _wallets(address a) internal pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = a; + } + + function _claimParams(uint64 id, address[] memory wallets_) internal pure returns (bytes memory) { + return abi.encode(id, wallets_); + } + + function _claim(uint64 id, address[] memory wallets_) internal returns (uint32 exitCode, uint256[] memory amounts) { + bytes memory data; + (exitCode, data) = _call(CLAIM, _claimParams(id, wallets_)); + if (exitCode == 0) amounts = abi.decode(data, (uint256[])); + } + + function _payableRow(LedgerRow[] memory rows, address wallet) internal pure returns (uint256) { + for (uint256 i = 0; i < rows.length; i++) { + if (rows[i].wallet == wallet) return rows[i].amount; + } + return 0; + } + + // The objection window is 7 days; epochs are 30s. function test_SwaTimelock_IsSevenDaysOfEpochs() public pure { assertEq(SWA_TIMELOCK, 7 * 24 * 60 * 60 / 30); } + function test_SwaTimelockEpochs_DefaultsToConstant() public view { + assertEq(rewardActor().swaTimelockEpochs(), SWA_TIMELOCK); + } + + function test_MockSwaTimelockEpochs_Overrides() public { + rewardActor().mockSwaTimelockEpochs(10); + + // _registerParams hardcodes SWA_TIMELOCK, not the override, so build params directly. + bytes memory params = abi.encode( + SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0), uint64(block.number) + 10 + ); + (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, params); + assertEq(exitCode, 0); + + vm.roll(block.number + 10); + assertEq(_streams().length, 1, "must settle after the overridden (short) hold"); + } + // ------------------------------------------------------------------------- - // ComputeWeight -- clamp(v_start + slope * (e - t_start), floor, cap) + // ClampWeight -- clamp(v_start + slope * (e - t_start), floor, cap). Not a dispatched + // method; exposed directly. // ------------------------------------------------------------------------- - function _computeWeight(WeightRecord memory record, uint64 epoch) internal returns (int256) { - (uint32 exitCode, bytes memory data) = _call(COMPUTE_WEIGHT, abi.encode(record, epoch)); - assertEq(exitCode, 0); - return abi.decode(data, (int256)); + function _clampWeight(WeightRecord memory record, uint64 epoch) internal pure returns (int256) { + return rewardActor().clampWeight(record, epoch); } - function test_ComputeWeight_AtTStart_ReturnsVStart() public { + function test_ClampWeight_AtTStart_ReturnsVStart() public pure { WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); - assertEq(_computeWeight(r, 1000), 0.95e18); + assertEq(_clampWeight(r, 1000), 0.95e18); } - function test_ComputeWeight_MidRamp_IsLinear() public { + function test_ClampWeight_MidRamp_IsLinear() public pure { WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); // 500 epochs after t_start: 0.95e18 - 100*500 = 0.95e18 - 50000 - assertEq(_computeWeight(r, 1500), 0.95e18 - 50_000); + assertEq(_clampWeight(r, 1500), 0.95e18 - 50_000); } - function test_ComputeWeight_PastFloor_ClampsToFloor() public { + function test_ClampWeight_PastFloor_ClampsToFloor() public pure { WeightRecord memory r = _record(0.95e18, -1e17, 1000, 0.5e18, 0.95e18); // 10 epochs after t_start at slope -1e17/epoch is already far past the 0.5e18 floor. - assertEq(_computeWeight(r, 1010), 0.5e18); + assertEq(_clampWeight(r, 1010), 0.5e18); } - function test_ComputeWeight_BeforeTStart_ClampsToCap() public { + function test_ClampWeight_BeforeTStart_ClampsToCap() public pure { // e < t_start with a negative slope means (e - t_start) < 0, so raw > v_start = cap. WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); - assertEq(_computeWeight(r, 0), 0.95e18); + assertEq(_clampWeight(r, 0), 0.95e18); } - function test_ComputeWeight_ZeroSlope_IsConstant() public { + function test_ClampWeight_ZeroSlope_IsConstant() public pure { WeightRecord memory r = _constantRecord(0.3e18); - assertEq(_computeWeight(r, 0), 0.3e18); - assertEq(_computeWeight(r, 1_000_000), 0.3e18); + assertEq(_clampWeight(r, 0), 0.3e18); + assertEq(_clampWeight(r, 1_000_000), 0.3e18); } - // ------------------------------------------------------------------------- - // GetState - // ------------------------------------------------------------------------- - function test_GetState_Empty_ReturnsNoStreams() public { - (uint64[] memory ids,,,,) = _getState(); - assertEq(ids.length, 0); + assertEq(_streams().length, 0); } function test_GetState_ReflectsRegisteredStream_WithMatchingWeight() public { @@ -185,25 +266,16 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(SERVICE_ID, r, DistributionKind.EXPLICIT, address(writerCaller)), 0); _warpPastTimelockAndSettle(); - ( - uint64[] memory ids, - WeightRecord[] memory records, - DistributionKind[] memory kinds, - address[] memory writers, - int256[] memory weights - ) = _getState(); - assertEq(ids.length, 1); - assertEq(ids[0], SERVICE_ID); - assertEq(records[0].vStart, r.vStart); - assertEq(uint8(kinds[0]), uint8(DistributionKind.EXPLICIT)); - assertEq(writers[0], address(writerCaller)); - assertEq(weights[0], _computeWeight(r, uint64(block.number))); + StreamView[] memory streams = _streams(); + assertEq(streams.length, 1); + assertEq(streams[0].id, SERVICE_ID); + assertEq(streams[0].weightRecord.vStart, r.vStart); + assertEq(uint8(streams[0].kind), uint8(DistributionKind.EXPLICIT)); + assertEq(streams[0].writer, address(writerCaller)); + assertEq(streams[0].weight, _clampWeight(r, uint64(block.number))); + assertEq(streams[0].accrued, 0); } - // ------------------------------------------------------------------------- - // RegisterStream - // ------------------------------------------------------------------------- - function test_RegisterStream_NotSwa_Forbidden() public { (uint32 exitCode,) = randomCaller.call( REGISTER_STREAM, _registerParams(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)) @@ -281,18 +353,38 @@ contract FVMRewardActorTest is MockRewardTest { function test_RegisterStream_PendingUntilActivation() public { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); - (uint64[] memory ids,,,,) = _getState(); - assertEq(ids.length, 0, "stream must not be live before its activation epoch"); + assertEq(_streams().length, 0, "stream must not be live before its activation epoch"); _warpPastTimelockAndSettle(); - (uint64[] memory idsAfter,,,,) = _getState(); - assertEq(idsAfter.length, 1); - assertEq(idsAfter[0], SERVICE_ID); + StreamView[] memory streams = _streams(); + assertEq(streams.length, 1); + assertEq(streams[0].id, SERVICE_ID); } - // ------------------------------------------------------------------------- - // SetWeightRecords - // ------------------------------------------------------------------------- + function test_RegisterStream_TombstonedId_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setSharesExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setSharesExit, 0); + vm.deal(address(rewardActor()), 1 ether); + rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed + + (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(removeExit, 0); + _warpPastTimelockAndSettle(); + + assertEq(_getState().tombstones.length, 1, "sanity: the outstanding payable produced a tombstone"); + + // f02 rejects any id collision it can see, including an undrained tombstone. + assertEq( + _registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), + USR_ILLEGAL_ARGUMENT + ); + + // Once the tombstone drains, the id is free again (f02 doesn't remember past that). + _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + } function _setWeightParams(uint64 id, WeightRecord memory record) internal pure returns (bytes memory) { uint64[] memory ids = new uint64[](1); @@ -344,42 +436,71 @@ contract FVMRewardActorTest is MockRewardTest { (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.7e18))); assertEq(setExit, 0); - (, WeightRecord[] memory recordsBefore,,,) = _getState(); - assertEq(recordsBefore[0].vStart, 0.1e18, "must not apply before the timelock elapses"); + StreamView[] memory before = _streams(); + assertEq(before[0].weightRecord.vStart, 0.1e18, "must not apply before the timelock elapses"); _warpPastTimelockAndSettle(); - (, WeightRecord[] memory recordsAfter,,,) = _getState(); - assertEq(recordsAfter[0].vStart, 0.7e18); + StreamView[] memory afterSettle = _streams(); + assertEq(afterSettle[0].weightRecord.vStart, 0.7e18); } // ------------------------------------------------------------------------- - // SetShares + // StepWeightRecords -- queued under its own (id, STEP_WEIGHT) slot, coexists with SetWeightRecords. // ------------------------------------------------------------------------- - function _shares(address wallet, uint256 amount) internal pure returns (Share[] memory arr) { - arr = new Share[](1); - arr[0] = Share({wallet: wallet, share: amount}); + function test_StepWeightRecords_NotSwa_Forbidden() public { + (uint32 exitCode,) = + randomCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + assertEq(exitCode, USR_FORBIDDEN); } - function _setSharesParams(uint64 id, Share[] memory shares_) internal pure returns (bytes memory) { - return abi.encode(id, shares_); + function test_StepWeightRecords_QueuedUntilTimelockElapses() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + (uint32 setExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.15e18))); + assertEq(setExit, 0); + assertEq(_streams()[0].weightRecord.vStart, 0.1e18, "must not apply before the timelock elapses"); + + _warpPastTimelockAndSettle(); + assertEq(_streams()[0].weightRecord.vStart, 0.15e18); } - function _registerExplicit(uint64 id, address writer) internal { - assertEq(_registerStream(id, _constantRecord(0.1e18), DistributionKind.EXPLICIT, writer), 0); + function test_StepWeightRecords_And_SetWeightRecords_AreIndependentSlots() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); + + // Both queue successfully: distinct (id, op) slots. + (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.15e18))); + assertEq(stepExit, 0); + (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.2e18))); + assertEq(setExit, 0); + + assertEq(_getState().pendingWrites.length, 2); + } + + function test_StepWeightRecords_OccupiedSlot_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + (uint32 firstExit,) = + swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.15e18))); + assertEq(firstExit, 0); + (uint32 secondExit,) = + swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.2e18))); + assertEq(secondExit, USR_ILLEGAL_ARGUMENT, "revising a pending write is cancel + requeue, not a second queue"); } function test_SetShares_NotWriter_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); (uint32 exitCode,) = - randomCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + randomCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(exitCode, USR_FORBIDDEN); } function test_SetShares_NonexistentStream_NotFound() public { (uint32 exitCode,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(exitCode, USR_NOT_FOUND); } @@ -387,17 +508,17 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); (uint32 exitCode,) = - randomCaller.call(SET_SHARES, _setSharesParams(CONSENSUS_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + randomCaller.call(SET_SHARES, _setSharesParams(CONSENSUS_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } function test_SetShares_DoesNotSumToOne_IllegalArgument() public { _registerExplicit(SERVICE_ID, address(writerCaller)); (uint32 under,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL - 1))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL - 1))); assertEq(under, USR_ILLEGAL_ARGUMENT); (uint32 over,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL + 1))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL + 1))); assertEq(over, USR_ILLEGAL_ARGUMENT); } @@ -414,20 +535,41 @@ contract FVMRewardActorTest is MockRewardTest { function test_SetShares_Valid_AppliesImmediately_NoTimelock() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - Share[] memory shares_ = _shares(address(0xBEEF), SHARE_TOTAL); + Share[] memory shares_ = _shares(RECIPIENT_A, SHARE_TOTAL); (uint32 exitCode,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, shares_)); assertEq(exitCode, 0); - // No vm.roll here: SetShares is the writer's own write, not queued under SWA_TIMELOCK. + // No vm.roll here: SetShares is the writer's own write, not queued under the timelock. Share[] memory got = rewardActor().getShares(SERVICE_ID); assertEq(got.length, 1); - assertEq(got[0].wallet, address(0xBEEF)); + assertEq(got[0].wallet, RECIPIENT_A); assertEq(got[0].share, SHARE_TOTAL); } - // ------------------------------------------------------------------------- - // RemoveStream - // ------------------------------------------------------------------------- + function test_SetShares_FoldsAccruedIntoPayable_AndBurnsResidue() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 + (uint32 firstSetExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(firstSetExit, 0); + + vm.deal(address(rewardActor()), 1 ether); + rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether + + uint256 burnBefore = BURN_ADDRESS.balance; + (uint32 exitCode,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL))); + assertEq(exitCode, 0); + + // Sole recipient held 100%, so folding leaves no rounding residue. + LedgerRow[] memory payableRows = rewardActor().getPayable(SERVICE_ID); + assertEq(_payableRow(payableRows, RECIPIENT_A), 0.1 ether); + assertEq(BURN_ADDRESS.balance, burnBefore, "an exact 100% share leaves no rounding dust to burn"); + assertEq(_streams()[0].accrued, 0, "accrual resets after the fold"); + + // New map installed for the next period. + Share[] memory got = rewardActor().getShares(SERVICE_ID); + assertEq(got[0].wallet, RECIPIENT_B); + } function test_RemoveStream_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); @@ -443,24 +585,54 @@ contract FVMRewardActorTest is MockRewardTest { function test_RemoveStream_QueuedThenRemoved_AndSharesCleared() public { _registerExplicit(SERVICE_ID, address(writerCaller)); (uint32 setExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(setExit, 0); (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); assertEq(removeExit, 0); - - (uint64[] memory idsBefore,,,,) = _getState(); - assertEq(idsBefore.length, 1, "must still be live before the timelock elapses"); + assertEq(_streams().length, 1, "must still be live before the timelock elapses"); _warpPastTimelockAndSettle(); - (uint64[] memory idsAfter,,,,) = _getState(); - assertEq(idsAfter.length, 0); + assertEq(_streams().length, 0); assertEq(rewardActor().getShares(SERVICE_ID).length, 0); } - // ------------------------------------------------------------------------- - // SetDistribution - // ------------------------------------------------------------------------- + function test_RemoveStream_NoOutstandingPayable_NoTombstoneCreated() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(removeExit, 0); + _warpPastTimelockAndSettle(); + + assertEq(_getState().tombstones.length, 0, "nothing was ever owed, so nothing needs to stay addressable"); + } + + function test_RemoveStream_OutstandingPayable_MovesToTombstone_ClaimableAfterRemoval() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setSharesExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setSharesExit, 0); + + vm.deal(address(rewardActor()), 1 ether); + rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed + + (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + assertEq(removeExit, 0); + _warpPastTimelockAndSettle(); + + assertEq(_streams().length, 0); + TombstoneView[] memory tombstones = _getState().tombstones; + assertEq(tombstones.length, 1); + assertEq(tombstones[0].id, SERVICE_ID); + assertEq(_payableRow(tombstones[0].payableRows, RECIPIENT_A), 0.1 ether); + + (uint32 claimExit, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(claimExit, 0); + assertEq(amounts[0], 0.1 ether); + assertEq(RECIPIENT_A.balance, 0.1 ether); + + // Fully claimed: the tombstone drains and deletes. + assertEq(_getState().tombstones.length, 0); + } function test_SetDistribution_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); @@ -484,12 +656,12 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } - // FIP-0118 2.4 item 9: "The stream's current wallet-to-share map remains in force until - // the new writer overwrites it via SetShares, so payments continue across the transition." + // The stream's current wallet-to-share map remains in force until the new writer + // overwrites it via SetShares, so payments continue across the transition. function test_SetDistribution_OldWriterStaysAuthorizedUntilTimelockElapses() public { _registerExplicit(SERVICE_ID, address(writerCaller)); (uint32 initialSet,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xBEEF), SHARE_TOTAL))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(initialSet, 0); (uint32 distExit,) = swaCaller.call( @@ -497,10 +669,9 @@ contract FVMRewardActorTest is MockRewardTest { ); assertEq(distExit, 0); - // Before the timelock elapses: old writer still authorized, new writer is not, and - // the existing map (from initialSet) is untouched. + // Before the timelock elapses: old writer still authorized, new writer is not. (uint32 oldWriterExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xCAFE), SHARE_TOTAL))); + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL))); assertEq(oldWriterExit, 0); (uint32 newWriterExitTooEarly,) = otherWriterCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); @@ -518,21 +689,39 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(rewardActor().getShares(SERVICE_ID)[0].wallet, address(0xF00D)); } - // ------------------------------------------------------------------------- - // CancelPending - // ------------------------------------------------------------------------- + function test_SetDistribution_ApplyFoldsAccruedIntoPayable() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setSharesExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setSharesExit, 0); + + vm.deal(address(rewardActor()), 1 ether); + rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues under RECIPIENT_A + + (uint32 distExit,) = swaCaller.call( + SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) + ); + assertEq(distExit, 0); + _warpPastTimelockAndSettle(); + + assertEq(_payableRow(rewardActor().getPayable(SERVICE_ID), RECIPIENT_A), 0.1 ether); + assertEq(_streams()[0].accrued, 0); + // The share map itself is untouched by the writer change. + assertEq(rewardActor().getShares(SERVICE_ID)[0].wallet, RECIPIENT_A); + } function test_CancelPending_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); assertEq(setExit, 0); - (uint32 exitCode,) = randomCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + (uint32 exitCode,) = randomCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); assertEq(exitCode, USR_FORBIDDEN); } - function test_CancelPending_NoPending_NotFound() public { - (uint32 exitCode,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); - assertEq(exitCode, USR_NOT_FOUND); + // Cancelling a slot with nothing queued must succeed as a no-op, not error. + function test_CancelPending_EmptySlot_BenignNoOp() public { + (uint32 exitCode,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + assertEq(exitCode, 0); } function test_CancelPending_DiscardsQueuedSetWeightRecords() public { @@ -540,22 +729,20 @@ contract FVMRewardActorTest is MockRewardTest { (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); assertEq(setExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); - (, WeightRecord[] memory records,,,) = _getState(); - assertEq(records[0].vStart, 0.1e18, "cancelled write must never apply"); + assertEq(_streams()[0].weightRecord.vStart, 0.1e18, "cancelled write must never apply"); } function test_CancelPending_DiscardsQueuedRegisterStream() public { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.REGISTER)); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); - (uint64[] memory ids,,,,) = _getState(); - assertEq(ids.length, 0, "cancelled registration must never take effect"); + assertEq(_streams().length, 0, "cancelled registration must never take effect"); } function test_CancelPending_DiscardsQueuedRemoveStream() public { @@ -563,20 +750,149 @@ contract FVMRewardActorTest is MockRewardTest { (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); assertEq(removeExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID)); + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.REMOVE)); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + assertEq(_streams().length, 1, "cancelled removal must never take effect"); + } + + // "No cancel path" is SWA-side discipline; f02 itself treats every op uniformly. + function test_CancelPending_StepWeightRecords_IsCancellableLikeAnyOtherOp() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); + assertEq(stepExit, 0); + + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.STEP_WEIGHT)); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); - (uint64[] memory ids,,,,) = _getState(); - assertEq(ids.length, 1, "cancelled removal must never take effect"); + assertEq(_streams()[0].weightRecord.vStart, 0.1e18); + } + + function test_CancelPending_OnlyCancelsMatchingOp() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.5e18))); + assertEq(setExit, 0); + (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.6e18))); + assertEq(stepExit, 0); + + (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + // SET_WEIGHT was cancelled; STEP_WEIGHT still applied. + assertEq(_streams()[0].weightRecord.vStart, 0.6e18); + } + + function test_Claim_NonexistentStream_NotFound() public { + (uint32 exitCode,) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(exitCode, USR_NOT_FOUND); + } + + function test_Claim_ImplicitStream_IllegalArgument() public { + assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + (uint32 exitCode,) = _claim(CONSENSUS_ID, _wallets(RECIPIENT_A)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + function test_Claim_UnknownWallet_ReturnsZero_NoRevert() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setSharesExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setSharesExit, 0); + + (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_B)); + assertEq(exitCode, 0, "an all-zero batch is a benign no-op success"); + assertEq(amounts[0], 0); + } + + function test_Claim_PaysLiveAccrual() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 + (uint32 setSharesExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setSharesExit, 0); + + vm.deal(address(rewardActor()), 1 ether); + rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether + + (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(exitCode, 0); + assertEq(amounts[0], 0.1 ether); + assertEq(RECIPIENT_A.balance, 0.1 ether); + assertEq(_streams()[0].accrued, 0.1 ether, "accrued is a period gross total, unaffected by claims"); + + // A second claim in the same period pays nothing further: claimed_period tracks it. + (, uint256[] memory secondAmounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(secondAmounts[0], 0); + assertEq(RECIPIENT_A.balance, 0.1 ether); + } + + function test_Claim_PaysPayablePlusLive() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 firstSetExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(firstSetExit, 0); + + vm.deal(address(rewardActor()), 2 ether); + rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed + + // Fold the period (via a new SetShares) so the 0.1 ether moves into payable. + (uint32 secondSetExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(secondSetExit, 0); + assertEq(_payableRow(rewardActor().getPayable(SERVICE_ID), RECIPIENT_A), 0.1 ether); + + rewardActor().mockAwardBlockReward(1 ether); // another 0.1 ether accrues, live this period + + (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(exitCode, 0); + assertEq(amounts[0], 0.2 ether, "payable (closed period) plus live (current period)"); + assertEq(RECIPIENT_A.balance, 0.2 ether); + assertEq(rewardActor().getPayable(SERVICE_ID).length, 0, "claimed payable row drops"); + } + + function test_AwardBlockReward_SplitsAcrossMinerAndService_ConservationHolds() public { + assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.85e18), DistributionKind.IMPLICIT, address(0)), 0); + assertEq( + _registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.EXPLICIT, address(writerCaller)), 0 + ); + _warpPastTimelockAndSettle(); + + vm.deal(address(rewardActor()), 1 ether); + (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(1 ether); + + assertEq(minerPortion, 0.85 ether); + assertEq(servicePortion, 0.1 ether); + assertEq(burnAmount, 0.05 ether); + assertEq(minerPortion + servicePortion + burnAmount, 1 ether, "conservation: BR = miner + service + burn"); + + assertEq(rewardActor().totalMintedReward(), 1 ether); + assertEq(rewardActor().totalBurnMinted(), 0.05 ether); + assertEq(rewardActor().totalServiceMinted(), 0.1 ether); + // M = T - B - S is derived, never stored. + assertEq( + rewardActor().totalMintedReward() - rewardActor().totalBurnMinted() - rewardActor().totalServiceMinted(), + minerPortion + ); + assertEq(BURN_ADDRESS.balance, 0.05 ether); + } + + function test_AwardBlockReward_NoStreams_AllBurn() public { + vm.deal(address(rewardActor()), 1 ether); + (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(1 ether); + assertEq(minerPortion, 0); + assertEq(servicePortion, 0); + assertEq(burnAmount, 1 ether); + assertEq(BURN_ADDRESS.balance, 1 ether); } // ------------------------------------------------------------------------- // Fidelity: native-actor fallback and precompile guard // ------------------------------------------------------------------------- - // The real reward actor is a native actor: direct EVM CALL (InvokeContract) fails with - // USR_UNHANDLED_MESSAGE rather than succeeding like an account actor would. + // A native actor: direct EVM CALL fails with USR_UNHANDLED_MESSAGE, not like an account actor. function test_DirectEvmCall_ReturnsUnhandledMessage() public { (bool ok, bytes memory ret) = REWARD_ACTOR_ADDRESS.call(""); assertTrue(ok); @@ -594,9 +910,8 @@ contract FVMRewardActorTest is MockRewardTest { // Regression: FVMCallActorByIdWithReward must not break existing actor mocks // ------------------------------------------------------------------------- - // Exercises _handleBurn's `address(this).balance` debit through the new dispatcher -- - // the specific behavior a `call`-based (rather than `delegatecall`-based) forward to the - // underlying FVMCallActorById would have broken. + // Exercises _handleBurn's balance debit through the new dispatcher -- would break if the + // forward to FVMCallActorById used `call` instead of `delegatecall`. function test_Regression_Burn_StillDebitsCallerBalance() public { uint256 before = address(this).balance; assertEq(BURN_ADDRESS.balance, 0); diff --git a/test/mocks/FVMRewardMethod.sol b/test/mocks/FVMRewardMethod.sol index e08937b..ef3343e 100644 --- a/test/mocks/FVMRewardMethod.sol +++ b/test/mocks/FVMRewardMethod.sol @@ -2,20 +2,19 @@ pragma solidity ^0.8.36; // FRC-0042 method numbers (first 4 bytes of blake2b-512("1|" + MethodName), rejection-sampled -// above 1<<24) for the new f02 (Reward actor) methods proposed by draft FIP-0118 -// (https://github.com/filecoin-project/FIPs/pull/1270) and tracked in -// https://github.com/filecoin-project/builtin-actors/issues/1764. Not yet implemented in -// builtin-actors; defined here so solstice's contracts and this mock agree on the same -// method numbers ahead of the real actor shipping them. +// above 1<<24) for the f02 (Reward actor) stream-splitting methods; defined here so +// solstice's contracts and this mock agree on the same numbers. uint64 constant SET_WEIGHT_RECORDS = 3362570548; +uint64 constant STEP_WEIGHT_RECORDS = 3951753085; uint64 constant SET_SHARES = 2414422607; uint64 constant GET_STATE = 1397113977; uint64 constant REGISTER_STREAM = 386660827; uint64 constant REMOVE_STREAM = 1623858416; uint64 constant SET_DISTRIBUTION = 3872725033; uint64 constant CANCEL_PENDING = 187585191; -uint64 constant COMPUTE_WEIGHT = 2393050123; +uint64 constant CLAIM = 4045527845; -/// @dev FIP-0118 section 2.4: the activation timelock the SWA's writes to f02 are queued -/// under, equal to the Section 4 objection window (7 days), in epochs (30s/epoch). +/// @dev The activation timelock SWA writes to f02 are queued under: the mainnet default, +/// 7 days in epochs (30s/epoch). Also exposed as mutable state (`swaTimelockEpochs`) since +/// it's migration-set per network. uint64 constant SWA_TIMELOCK = 20160; diff --git a/test/mocks/MockRewardTest.sol b/test/mocks/MockRewardTest.sol index 4865adf..18c6318 100644 --- a/test/mocks/MockRewardTest.sol +++ b/test/mocks/MockRewardTest.sol @@ -8,14 +8,13 @@ import {REWARD_ACTOR_ADDRESS} from "fvm-solidity/FVMActors.sol"; import {FVMCallActorByIdWithReward} from "./FVMCallActorByIdWithReward.sol"; import {FVMRewardActor} from "./FVMRewardActor.sol"; -/// @notice Extends fvm-solidity's MockFVMTest with a mock f02 (Reward actor) covering the -/// stream-splitting methods proposed in FIP-0118 -/// (https://github.com/filecoin-project/FIPs/pull/1270, tracked in -/// https://github.com/filecoin-project/builtin-actors/issues/1764). +/// @notice Extends fvm-solidity's MockFVMTest with a mock f02 (Reward actor) covering its +/// stream-splitting methods. contract MockRewardTest is MockFVMTest { function setUp() public virtual override { super.setUp(); vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardActor()).code); + FVMRewardActor(REWARD_ACTOR_ADDRESS).mockInit(); vm.etch( CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm, FVMRewardActor(REWARD_ACTOR_ADDRESS))).code ); From b031b14ed7a27d5bf11643a23a03529269c3028e Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 15:06:24 -0500 Subject: [PATCH 03/14] fix(f02-mocks): reject duplicate stream ids in one weight-write batch Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMRewardActor.sol | 4 ++++ test/mocks/FVMRewardActor.t.sol | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 2e148ff..883671d 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -278,6 +278,10 @@ contract FVMRewardActor { if (!_streams[ids[i]].exists) return (USR_NOT_FOUND, 0, ""); if (!_sane(records[i])) return (USR_ILLEGAL_ARGUMENT, 0, ""); if (_pendingExists[ids[i]][op]) return (USR_ILLEGAL_ARGUMENT, 0, ""); + // Reject repeats: they'd queue two PendingKeys for one (id, op) slot. + for (uint256 j = 0; j < i; j++) { + if (ids[j] == ids[i]) return (USR_ILLEGAL_ARGUMENT, 0, ""); + } } // Guardrail: sum of every stream's weight, including the proposed ones, must not exceed 1. int256 sum = _sumWeightsExcluding(ids, effectiveEpoch); diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 7d56438..c45f2d9 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -412,6 +412,21 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } + function test_SetWeightRecords_DuplicateIdInBatch_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + uint64[] memory ids = new uint64[](2); + ids[0] = SERVICE_ID; + ids[1] = SERVICE_ID; + WeightRecord[] memory records = new WeightRecord[](2); + records[0] = _constantRecord(0.5e18); + records[1] = _constantRecord(0.5e18); + + (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, abi.encode(ids, records)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT, "a repeated id queues two PendingKeys for one slot"); + } + function test_SetWeightRecords_InsaneRecord_IllegalArgument() public { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); From 21cca9dc4400b5ec38cd7dce2dd8fe3555f7fc41 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 15:12:30 -0500 Subject: [PATCH 04/14] fix(f02-mocks): count pending weight writes in the WAD sum guardrail Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMRewardActor.sol | 20 +++++++++++++++++--- test/mocks/FVMRewardActor.t.sol | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 883671d..058834a 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -545,8 +545,7 @@ contract FVMRewardActor { return w.floor >= 0 && w.floor <= w.cap && w.cap <= WAD; } - /// @dev Sum of every registered stream's weight at `atEpoch`, excluding `excludeIds`; a - /// not-yet-settled pending write still counts at its currently stored record. + /// @dev Sum of every registered stream's weight at `atEpoch`, excluding `excludeIds`. function _sumWeightsExcluding(uint64[] memory excludeIds, uint64 atEpoch) internal view returns (int256 sum) { for (uint256 i = 0; i < _streamIds.length; i++) { uint64 id = _streamIds[i]; @@ -557,7 +556,22 @@ contract FVMRewardActor { break; } } - if (!excluded) sum += _clampWeight(_streams[id].weightRecord, atEpoch); + if (!excluded) sum += _effectiveWeight(id, atEpoch); + } + } + + /// @dev A stream's weight at `atEpoch`, using a still-pending SET_WEIGHT/STEP_WEIGHT write's + /// record instead of the stale settled one when queued (the larger of the two if both are + /// queued), so the WAD guardrail can't be bypassed by splitting increases across batches. + function _effectiveWeight(uint64 id, uint64 atEpoch) internal view returns (int256 w) { + w = _clampWeight(_streams[id].weightRecord, atEpoch); + if (_pendingExists[id][PendingOp.SET_WEIGHT]) { + int256 pw = _clampWeight(_pending[id][PendingOp.SET_WEIGHT].weightRecord, atEpoch); + if (pw > w) w = pw; + } + if (_pendingExists[id][PendingOp.STEP_WEIGHT]) { + int256 pw = _clampWeight(_pending[id][PendingOp.STEP_WEIGHT].weightRecord, atEpoch); + if (pw > w) w = pw; } } diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index c45f2d9..9e84b48 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -444,6 +444,23 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } + function test_SetWeightRecords_SumWouldExceedOne_WithPendingWrite_IllegalArgument() public { + assertEq(_registerStream(SERVICE_ID, _constantRecord(0.4e18), DistributionKind.IMPLICIT, address(0)), 0); + assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.4e18), DistributionKind.IMPLICIT, address(0)), 0); + _warpPastTimelockAndSettle(); + + (uint32 firstExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.6e18))); + assertEq(firstExit, 0); + + (uint32 secondExit,) = + swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(CONSENSUS_ID, _constantRecord(0.6e18))); + assertEq( + secondExit, + USR_ILLEGAL_ARGUMENT, + "guardrail must count SERVICE_ID's still-pending 0.6e18, not its stale 0.4e18" + ); + } + function test_SetWeightRecords_QueuedUntilTimelockElapses() public { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); From 1da292195ef1786bfd0c0c22890bee2e370e0f72 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 18:16:34 -0500 Subject: [PATCH 05/14] fix(f02-mocks): self-issue block rewards so claims can't fail on funds Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMRewardActor.sol | 14 ++++++++++++-- test/mocks/FVMRewardActor.t.sol | 22 ++++++++++++++-------- test/mocks/MockRewardTest.sol | 2 +- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 058834a..4b557f5 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.36; +import {Vm} from "forge-std/Vm.sol"; + import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol"; import {FVMPay} from "fvm-solidity/FVMPay.sol"; @@ -134,6 +136,13 @@ struct PendingView { /// @dev GetState persists due writes rather than only projecting them; behaviorally identical /// once `effectiveEpoch` has passed. contract FVMRewardActor { + /// @dev Survives vm.etch: immutables are baked into runtime bytecode at deploy time. + Vm private immutable VM; + + constructor(Vm vm_) { + VM = vm_; + } + /// @notice Address authorized to call the SWA-only methods. address public swa; @@ -185,12 +194,13 @@ contract FVMRewardActor { /// @notice Test helper: simulates AwardBlockReward, splitting `br` by clamped weight into a /// miner portion (IMPLICIT; the actual payout is the unmocked ApplyRewards path), a service /// portion (EXPLICIT, accrues for Claim/SetShares), and a burn residual. - /// @dev Caller must vm.deal this contract's balance up by `br` first -- a block reward is - /// newly minted, not moved from an existing balance. + /// @dev Mints `br` into this contract's own balance via vm.deal -- a block reward is newly + /// issued, not moved from an existing balance, so callers don't pre-fund it themselves. function mockAwardBlockReward(uint256 br) external returns (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) { + VM.deal(address(this), address(this).balance + br); _settle(); uint64 nowEpoch = uint64(block.number); for (uint256 i = 0; i < _streamIds.length; i++) { diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 9e84b48..7a24e94 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -366,7 +366,6 @@ contract FVMRewardActorTest is MockRewardTest { (uint32 setSharesExit,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(setSharesExit, 0); - vm.deal(address(rewardActor()), 1 ether); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); @@ -584,7 +583,6 @@ contract FVMRewardActorTest is MockRewardTest { writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(firstSetExit, 0); - vm.deal(address(rewardActor()), 1 ether); rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether uint256 burnBefore = BURN_ADDRESS.balance; @@ -644,7 +642,6 @@ contract FVMRewardActorTest is MockRewardTest { writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(setSharesExit, 0); - vm.deal(address(rewardActor()), 1 ether); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); @@ -727,7 +724,6 @@ contract FVMRewardActorTest is MockRewardTest { writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(setSharesExit, 0); - vm.deal(address(rewardActor()), 1 ether); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues under RECIPIENT_A (uint32 distExit,) = swaCaller.call( @@ -846,7 +842,6 @@ contract FVMRewardActorTest is MockRewardTest { writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(setSharesExit, 0); - vm.deal(address(rewardActor()), 1 ether); rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); @@ -867,7 +862,6 @@ contract FVMRewardActorTest is MockRewardTest { writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); assertEq(firstSetExit, 0); - vm.deal(address(rewardActor()), 2 ether); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed // Fold the period (via a new SetShares) so the 0.1 ether moves into payable. @@ -892,7 +886,6 @@ contract FVMRewardActorTest is MockRewardTest { ); _warpPastTimelockAndSettle(); - vm.deal(address(rewardActor()), 1 ether); (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(1 ether); assertEq(minerPortion, 0.85 ether); @@ -912,7 +905,6 @@ contract FVMRewardActorTest is MockRewardTest { } function test_AwardBlockReward_NoStreams_AllBurn() public { - vm.deal(address(rewardActor()), 1 ether); (uint256 minerPortion, uint256 servicePortion, uint256 burnAmount) = rewardActor().mockAwardBlockReward(1 ether); assertEq(minerPortion, 0); assertEq(servicePortion, 0); @@ -920,6 +912,20 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(BURN_ADDRESS.balance, 1 ether); } + function test_AwardBlockReward_SelfIssues_ClaimPaysWithoutPreDeal() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + (uint32 setExit,) = + writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + assertEq(setExit, 0); + + rewardActor().mockAwardBlockReward(1 ether); // no vm.deal beforehand + + (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A)); + assertEq(exitCode, 0); + assertEq(amounts[0], 0.1 ether); + assertEq(RECIPIENT_A.balance, 0.1 ether, "claim must actually move funds, not just report bookkeeping"); + } + // ------------------------------------------------------------------------- // Fidelity: native-actor fallback and precompile guard // ------------------------------------------------------------------------- diff --git a/test/mocks/MockRewardTest.sol b/test/mocks/MockRewardTest.sol index 18c6318..b0159d9 100644 --- a/test/mocks/MockRewardTest.sol +++ b/test/mocks/MockRewardTest.sol @@ -13,7 +13,7 @@ import {FVMRewardActor} from "./FVMRewardActor.sol"; contract MockRewardTest is MockFVMTest { function setUp() public virtual override { super.setUp(); - vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardActor()).code); + 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 From 364698b7f5a2c005d0e7be496300608241936451 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 18:23:15 -0500 Subject: [PATCH 06/14] refactor(f02-mocks): dedupe swap-and-pop array removal into _swapRemove Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMRewardActor.sol | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 4b557f5..93331e1 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -632,13 +632,24 @@ contract FVMRewardActor { function _removePendingKey(uint64 id, PendingOp op) internal { for (uint256 i = 0; i < _pendingKeys.length; i++) { if (_pendingKeys[i].id == id && _pendingKeys[i].op == op) { - _pendingKeys[i] = _pendingKeys[_pendingKeys.length - 1]; - _pendingKeys.pop(); + _swapRemove(_pendingKeys, i); return; } } } + /// @dev Remove index `i` by swapping in the last element; order is not preserved. + function _swapRemove(PendingKey[] storage arr, uint256 i) internal { + arr[i] = arr[arr.length - 1]; + arr.pop(); + } + + /// @dev Remove index `i` by swapping in the last element; order is not preserved. + function _swapRemove(uint64[] storage arr, uint256 i) internal { + arr[i] = arr[arr.length - 1]; + arr.pop(); + } + function _recomputeNextTransition() internal { uint64 best = type(uint64).max; for (uint256 i = 0; i < _pendingKeys.length; i++) { @@ -651,8 +662,16 @@ contract FVMRewardActor { function _removeTombstoneId(uint64 id) internal { for (uint256 i = 0; i < _tombstoneIds.length; i++) { if (_tombstoneIds[i] == id) { - _tombstoneIds[i] = _tombstoneIds[_tombstoneIds.length - 1]; - _tombstoneIds.pop(); + _swapRemove(_tombstoneIds, i); + return; + } + } + } + + function _removeStreamId(uint64 id) internal { + for (uint256 i = 0; i < _streamIds.length; i++) { + if (_streamIds[i] == id) { + _swapRemove(_streamIds, i); return; } } @@ -767,12 +786,6 @@ contract FVMRewardActor { _ledgerClearAll(s.payableLedger); delete _streams[id]; - for (uint256 i = 0; i < _streamIds.length; i++) { - if (_streamIds[i] == id) { - _streamIds[i] = _streamIds[_streamIds.length - 1]; - _streamIds.pop(); - break; - } - } + _removeStreamId(id); } } From 66b1128a535dc8c303a98573782d10dfbbed31d3 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 18:32:00 -0500 Subject: [PATCH 07/14] fix(f02-mocks): reject nonzero value on reward actor calls Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMCallActorByIdWithReward.sol | 15 +++++++++++---- test/mocks/FVMRewardActor.t.sol | 9 +++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/test/mocks/FVMCallActorByIdWithReward.sol b/test/mocks/FVMCallActorByIdWithReward.sol index c91c7aa..2a668ee 100644 --- a/test/mocks/FVMCallActorByIdWithReward.sol +++ b/test/mocks/FVMCallActorByIdWithReward.sol @@ -6,6 +6,7 @@ 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 {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"; @@ -39,14 +40,20 @@ contract FVMCallActorByIdWithReward { } } - (uint64 method,, uint64 flags, uint64 codec, bytes memory params, uint64 actorId) = + (uint64 method, uint256 value, uint64 flags, uint64 codec, bytes memory params, uint64 actorId) = abi.decode(msg.data, (uint64, uint256, uint64, uint64, bytes, uint64)); if (actorId == REWARD_ACTOR_ID) { require(flags == READONLY_FLAG || flags == NO_FLAGS, "FVMCallActorByIdWithReward: invalid flags"); - (uint32 exitCode, uint64 outCodec, bytes memory rewardRet) = - REWARD.handle_filecoin_method(method, codec, params); - bytes memory response = abi.encode(exitCode, outCodec, rewardRet); + bytes memory response; + if (value != 0) { + // 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); + } assembly ("memory-safe") { return(add(response, 0x20), mload(response)) } diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 7a24e94..1a95f21 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -944,6 +944,15 @@ contract FVMRewardActorTest is MockRewardTest { assertFalse(ok); } + // None of the reward actor's methods accept a value; the mock must not silently drop it. + function test_NonzeroValue_IllegalArgument() public { + bytes memory callData = abi.encode(GET_STATE, uint256(1), NO_FLAGS, uint64(0), bytes(""), REWARD_ACTOR_ID); + (bool ok, bytes memory ret) = CALL_ACTOR_BY_ID.delegatecall(callData); + assertTrue(ok); + (uint32 exitCode,,) = abi.decode(ret, (uint32, uint64, bytes)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + // ------------------------------------------------------------------------- // Regression: FVMCallActorByIdWithReward must not break existing actor mocks // ------------------------------------------------------------------------- From 3503749db14fc630ea31bc16e485f53843248e78 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Fri, 31 Jul 2026 19:22:07 -0500 Subject: [PATCH 08/14] refactor(f02-mocks): type MAX_STREAMS as uint64 to drop a cast Assisted-by: Claude:claude-sonnet-5 --- test/mocks/FVMRewardActor.sol | 2 +- test/mocks/FVMRewardActor.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 93331e1..347c431 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -23,7 +23,7 @@ import { int256 constant WAD = 1e18; /// @dev Mock-only caps; f02 requires these limits to exist but never fixes their values. -uint256 constant MAX_STREAMS = 8; +uint64 constant MAX_STREAMS = 8; uint256 constant MAX_RECIPIENTS = 64; /// @dev Same value as WAD, typed uint256, so summing shares needs no signed-to-unsigned cast. diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 1a95f21..97848ef 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -336,7 +336,7 @@ contract FVMRewardActorTest is MockRewardTest { for (uint64 i = 0; i < MAX_STREAMS; i++) { assertEq(_registerStream(i, _constantRecord(0), DistributionKind.IMPLICIT, address(0)), 0); } - uint64 oneMoreId = uint64(MAX_STREAMS); + uint64 oneMoreId = MAX_STREAMS; assertEq( _registerStream(oneMoreId, _constantRecord(0), DistributionKind.IMPLICIT, address(0)), USR_ILLEGAL_ARGUMENT ); From c1003cc4f04db107eb4066b05434b2c3de11832a Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 18:30:03 -0500 Subject: [PATCH 09/14] feat(lib): add FVMRewards library for the f02 Reward actor Assisted-by: Claude:claude-sonnet-4-6 --- {test/mocks => src/lib}/FVMRewardMethod.sol | 19 +- src/lib/FVMRewardTypes.sol | 37 + src/lib/FVMRewards.sol | 777 ++++++++++++++++++++ test/mocks/FVMRewardActor.sol | 330 ++++++++- test/mocks/FVMRewardActor.t.sol | 345 +++++---- 5 files changed, 1330 insertions(+), 178 deletions(-) rename {test/mocks => src/lib}/FVMRewardMethod.sol (53%) create mode 100644 src/lib/FVMRewardTypes.sol create mode 100644 src/lib/FVMRewards.sol diff --git a/test/mocks/FVMRewardMethod.sol b/src/lib/FVMRewardMethod.sol similarity index 53% rename from test/mocks/FVMRewardMethod.sol rename to src/lib/FVMRewardMethod.sol index ef3343e..b533dec 100644 --- a/test/mocks/FVMRewardMethod.sol +++ b/src/lib/FVMRewardMethod.sol @@ -1,20 +1,21 @@ // SPDX-License-Identifier: Apache-2.0 OR MIT pragma solidity ^0.8.36; -// FRC-0042 method numbers (first 4 bytes of blake2b-512("1|" + MethodName), rejection-sampled -// above 1<<24) for the f02 (Reward actor) stream-splitting methods; defined here so -// solstice's contracts and this mock agree on the same numbers. -uint64 constant SET_WEIGHT_RECORDS = 3362570548; -uint64 constant STEP_WEIGHT_RECORDS = 3951753085; -uint64 constant SET_SHARES = 2414422607; -uint64 constant GET_STATE = 1397113977; +// FRC-0042 method numbers for the f02 (Reward actor) stream-splitting methods proposed by +// FIP-1270 (https://github.com/filecoin-project/FIPs/pull/1270) and tracked upstream at +// filecoin-project/builtin-actors#1764. The actor does not exist yet; these numbers are the +// FRC-0042 hash of each method name, defined here so FVMRewards and its mock agree on them. uint64 constant REGISTER_STREAM = 386660827; uint64 constant REMOVE_STREAM = 1623858416; +uint64 constant SET_WEIGHT_RECORDS = 3362570548; +uint64 constant STEP_WEIGHT_RECORDS = 3951753085; uint64 constant SET_DISTRIBUTION = 3872725033; uint64 constant CANCEL_PENDING = 187585191; +uint64 constant SET_SHARES = 2414422607; uint64 constant CLAIM = 4045527845; +uint64 constant GET_STATE = 1397113977; /// @dev The activation timelock SWA writes to f02 are queued under: the mainnet default, -/// 7 days in epochs (30s/epoch). Also exposed as mutable state (`swaTimelockEpochs`) since -/// it's migration-set per network. +/// 7 days in epochs (30s/epoch); migration-set per network, so the real actor also exposes it +/// as mutable state rather than a hardcoded constant. uint64 constant SWA_TIMELOCK = 20160; diff --git a/src/lib/FVMRewardTypes.sol b/src/lib/FVMRewardTypes.sol new file mode 100644 index 0000000..da118b6 --- /dev/null +++ b/src/lib/FVMRewardTypes.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +/// @notice A stream's Distribution kind (FIP-1270 Section 2.4). +/// @dev IMPLICIT streams store no writer (f02 resolves the recipient from protocol state). +/// EXPLICIT streams are paid out per a wallet-to-share map written by their designated writer. +enum DistributionKind { + IMPLICIT, + EXPLICIT +} + +/// @notice A stream's weight schedule: clamp(vStart + slope * (epoch - tStart), floor, cap). +/// @dev WAD-scaled (1e18 == 1.0 == 100%); fields must fit int64 to be wire-encodable. +struct WeightRecord { + int256 vStart; + int256 slope; + uint64 tStart; + int256 floor; + int256 cap; +} + +/// @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. +struct Share { + address wallet; + uint256 share; +} + +/// @notice The kinds of queueable SWA discretionary writes a pending slot may hold. +enum PendingOp { + SET_WEIGHT, + STEP_WEIGHT, + REGISTER, + REMOVE, + SET_DISTRIBUTION +} diff --git a/src/lib/FVMRewards.sol b/src/lib/FVMRewards.sol new file mode 100644 index 0000000..899dead --- /dev/null +++ b/src/lib/FVMRewards.sol @@ -0,0 +1,777 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +import {REWARD_ACTOR_ID} from "fvm-solidity/FVMActors.sol"; +import {CBOR_CODEC} from "fvm-solidity/FVMCodec.sol"; +import {NO_FLAGS} from "fvm-solidity/FVMFlags.sol"; +import {EXIT_SUCCESS} from "fvm-solidity/FVMErrors.sol"; +import {CALL_ACTOR_BY_ID} from "fvm-solidity/FVMPrecompiles.sol"; + +import { + REGISTER_STREAM, + REMOVE_STREAM, + SET_WEIGHT_RECORDS, + SET_DISTRIBUTION, + CANCEL_PENDING, + SET_SHARES, + CLAIM +} from "./FVMRewardMethod.sol"; +import {WeightRecord, DistributionKind, Share, PendingOp} from "./FVMRewardTypes.sol"; + +/// @notice Calls the f02 (Reward actor) methods proposed by FIP-1270, for the Stream Weight +/// Actor (solstice#3) and Service Rewards Actor (solstice#4). f02 does not exist upstream yet +/// (filecoin-project/builtin-actors#1764); the wire format here is this repo's best-effort +/// CBOR encoding of the FIP's draft method signatures, not an upstream-confirmed ABI. +/// @dev Every write params blob is a CBOR array of positional fields; addresses are encoded as +/// CBOR byte strings wrapping an f410 delegated address (0x04, 0x0a, 20 address bytes). No +/// abi.encode/abi.decode: params are hand-built in assembly and Filecoin BigInt returns are +/// parsed directly out of returndata. Call-envelope scratch memory is never returned to Solidity +/// and so is used past the free memory pointer without bumping it (dies with the call); only +/// claim's decoded `amounts` array is a real, pointer-bumped allocation. +library FVMRewards { + error RegisterStreamFailed(int256 exitCode); + error RemoveStreamFailed(int256 exitCode); + error SetWeightRecordsFailed(int256 exitCode); + error SetDistributionFailed(int256 exitCode); + error CancelPendingFailed(int256 exitCode); + error SetSharesFailed(int256 exitCode); + error ClaimFailed(int256 exitCode); + + // ------------------------------------------------------------------------- + // RegisterStream -- SWA only + // ------------------------------------------------------------------------- + + /// @notice Calls RegisterStream on f02 without reverting on actor error. + /// @param id The new stream's id + /// @param record The stream's initial weight schedule + /// @param kind IMPLICIT (writer must be address(0)) or EXPLICIT (writer required) + /// @param writer The stream's designated writer for EXPLICIT streams; address(0) for IMPLICIT + /// @param activationEpoch The epoch the stream begins paying; must be >= now + SWA_TIMELOCK + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function tryRegisterStream( + uint64 id, + WeightRecord memory record, + DistributionKind kind, + address writer, + uint64 activationEpoch + ) internal returns (int256 exitCode) { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + function writeCborInt64(ptr, v) -> newPtr { + let neg := slt(v, 0) + let m := v + if neg { m := not(v) } + let base := 0 + if neg { base := 0x20 } + if lt(m, 24) { + mstore8(ptr, or(base, m)) + newPtr := add(ptr, 1) + leave + } + if lt(m, 0x100) { + mstore(ptr, shl(240, or(shl(8, or(base, 0x18)), m))) + newPtr := add(ptr, 2) + leave + } + if lt(m, 0x10000) { + mstore(ptr, shl(232, or(shl(16, or(base, 0x19)), m))) + newPtr := add(ptr, 3) + leave + } + if lt(m, 0x100000000) { + mstore(ptr, shl(216, or(shl(32, or(base, 0x1a)), m))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, or(base, 0x1b)), shl(184, m))) + newPtr := add(ptr, 9) + } + + let fmp := mload(0x40) + mstore(fmp, REGISTER_STREAM) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) // params offset + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [id, [vStart, slope, tStart, floor, cap], kind, writerOrNull, activationEpoch] + let p := add(fmp, 0xe0) + mstore8(p, 0x85) // 5-element array + p := add(p, 1) + p := writeCborUint64(p, id) + + mstore8(p, 0x85) // weight record: 5-element array + p := add(p, 1) + p := writeCborInt64(p, mload(record)) // vStart + p := writeCborInt64(p, mload(add(record, 0x20))) // slope + p := writeCborUint64(p, mload(add(record, 0x40))) // tStart + p := writeCborInt64(p, mload(add(record, 0x60))) // floor + p := writeCborInt64(p, mload(add(record, 0x80))) // cap + + mstore8(p, kind) // DistributionKind ordinal (0 or 1) fits inline CBOR uint + p := add(p, 1) + // Null iff writer is the zero address -- independent of `kind`, so a caller mistake + // (e.g. IMPLICIT with a nonzero writer) reaches the actor honestly instead of being + // silently corrected here. + switch iszero(writer) + case 1 { + mstore8(p, 0xf6) // CBOR null + p := add(p, 1) + } + default { + // CBOR bytes(22): f410 delegated address [0x04, 0x0a, 20 address bytes] + mstore(p, or(shl(232, 0x56040a), shl(72, writer))) + p := add(p, 23) + } + p := writeCborUint64(p, activationEpoch) + + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) // params length + + exitCode := not(0) // sentinel: precompile failure + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls RegisterStream on f02, reverting on actor error. + function registerStream( + uint64 id, + WeightRecord memory record, + DistributionKind kind, + address writer, + uint64 activationEpoch + ) internal { + int256 exitCode = tryRegisterStream(id, record, kind, writer, activationEpoch); + require(exitCode == EXIT_SUCCESS, RegisterStreamFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // RemoveStream -- SWA only + // ------------------------------------------------------------------------- + + /// @notice Calls RemoveStream on f02 without reverting on actor error. + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function tryRemoveStream(uint64 id) internal returns (int256 exitCode) { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + let fmp := mload(0x40) + mstore(fmp, REMOVE_STREAM) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: a bare uint64 (streamId), no array wrapper + let p := writeCborUint64(add(fmp, 0xe0), id) + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls RemoveStream on f02, reverting on actor error. + function removeStream(uint64 id) internal { + int256 exitCode = tryRemoveStream(id); + require(exitCode == EXIT_SUCCESS, RemoveStreamFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // SetWeightRecords -- SWA only, batch + // ------------------------------------------------------------------------- + + /// @notice Calls SetWeightRecords on f02 without reverting on actor error. + /// @param ids The streams to update; must be the same length as `records` + /// @param records Each id's new weight schedule + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function trySetWeightRecords(uint64[] memory ids, WeightRecord[] memory records) + internal + returns (int256 exitCode) + { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + function writeCborInt64(ptr, v) -> newPtr { + let neg := slt(v, 0) + let m := v + if neg { m := not(v) } + let base := 0 + if neg { base := 0x20 } + if lt(m, 24) { + mstore8(ptr, or(base, m)) + newPtr := add(ptr, 1) + leave + } + if lt(m, 0x100) { + mstore(ptr, shl(240, or(shl(8, or(base, 0x18)), m))) + newPtr := add(ptr, 2) + leave + } + if lt(m, 0x10000) { + mstore(ptr, shl(232, or(shl(16, or(base, 0x19)), m))) + newPtr := add(ptr, 3) + leave + } + if lt(m, 0x100000000) { + mstore(ptr, shl(216, or(shl(32, or(base, 0x1a)), m))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, or(base, 0x1b)), shl(184, m))) + newPtr := add(ptr, 9) + } + + function writeCborArrayHeader(ptr, count) -> newPtr { + switch lt(count, 24) + case 1 { + mstore8(ptr, or(0x80, count)) + newPtr := add(ptr, 1) + } + default { + switch lt(count, 0x100) + case 1 { + mstore8(ptr, 0x98) + mstore8(add(ptr, 1), count) + newPtr := add(ptr, 2) + } + default { + mstore8(ptr, 0x99) + mstore(add(ptr, 1), shl(240, count)) + newPtr := add(ptr, 3) + } + } + } + + let fmp := mload(0x40) + mstore(fmp, SET_WEIGHT_RECORDS) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [[id...], [[vStart,slope,tStart,floor,cap]...]]. ids and records are + // encoded with their own independent lengths -- a mismatch is left on the wire for + // the actor to reject, not resolved (let alone silently truncated) here. + let p := add(fmp, 0xe0) + mstore8(p, 0x82) + p := add(p, 1) + + let idsCount := mload(ids) + p := writeCborArrayHeader(p, idsCount) + let idsData := add(ids, 0x20) + for { let i := 0 } lt(i, idsCount) { i := add(i, 1) } { + p := writeCborUint64(p, mload(add(idsData, mul(i, 0x20)))) + } + + let recCount := mload(records) + p := writeCborArrayHeader(p, recCount) + let recData := add(records, 0x20) + for { let i := 0 } lt(i, recCount) { i := add(i, 1) } { + let rec := mload(add(recData, mul(i, 0x20))) + mstore8(p, 0x85) + p := add(p, 1) + p := writeCborInt64(p, mload(rec)) + p := writeCborInt64(p, mload(add(rec, 0x20))) + p := writeCborUint64(p, mload(add(rec, 0x40))) + p := writeCborInt64(p, mload(add(rec, 0x60))) + p := writeCborInt64(p, mload(add(rec, 0x80))) + } + + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls SetWeightRecords on f02, reverting on actor error. + function setWeightRecords(uint64[] memory ids, WeightRecord[] memory records) internal { + int256 exitCode = trySetWeightRecords(ids, records); + require(exitCode == EXIT_SUCCESS, SetWeightRecordsFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // SetDistribution -- SWA only + // ------------------------------------------------------------------------- + + /// @notice Calls SetDistribution on f02 without reverting on actor error. + /// @dev Converting a stream to IMPLICIT is not permitted by f02; kind must be EXPLICIT. + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function trySetDistribution(uint64 id, DistributionKind kind, address writer) internal returns (int256 exitCode) { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + let fmp := mload(0x40) + mstore(fmp, SET_DISTRIBUTION) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [id, kind, writerOrNull] + let p := add(fmp, 0xe0) + mstore8(p, 0x83) + p := add(p, 1) + p := writeCborUint64(p, id) + mstore8(p, kind) + p := add(p, 1) + // Null iff writer is the zero address -- independent of `kind` (see RegisterStream). + switch iszero(writer) + case 1 { + mstore8(p, 0xf6) + p := add(p, 1) + } + default { + mstore(p, or(shl(232, 0x56040a), shl(72, writer))) + p := add(p, 23) + } + + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls SetDistribution on f02, reverting on actor error. + function setDistribution(uint64 id, DistributionKind kind, address writer) internal { + int256 exitCode = trySetDistribution(id, kind, writer); + require(exitCode == EXIT_SUCCESS, SetDistributionFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // CancelPending -- SWA only + // ------------------------------------------------------------------------- + + /// @notice Calls CancelPending on f02 without reverting on actor error. + /// @dev Cancelling an already-empty (id, op) slot is a benign no-op success, not an error. + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function tryCancelPending(uint64 id, PendingOp op) internal returns (int256 exitCode) { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + let fmp := mload(0x40) + mstore(fmp, CANCEL_PENDING) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [id, op] + let p := add(fmp, 0xe0) + mstore8(p, 0x82) + p := add(p, 1) + p := writeCborUint64(p, id) + mstore8(p, op) + p := add(p, 1) + + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls CancelPending on f02, reverting on actor error. + function cancelPending(uint64 id, PendingOp op) internal { + int256 exitCode = tryCancelPending(id, op); + require(exitCode == EXIT_SUCCESS, CancelPendingFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // SetShares -- callable only by a stream's designated writer + // ------------------------------------------------------------------------- + + /// @notice Calls SetShares on f02 without reverting on actor error. + /// @dev shares[].share values must sum to exactly SHARE_TOTAL (1e18, WAD); f02 rejects otherwise. + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + function trySetShares(uint64 id, Share[] memory shares) internal returns (int256 exitCode) { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + function writeCborArrayHeader(ptr, count) -> newPtr { + switch lt(count, 24) + case 1 { + mstore8(ptr, or(0x80, count)) + newPtr := add(ptr, 1) + } + default { + switch lt(count, 0x100) + case 1 { + mstore8(ptr, 0x98) + mstore8(add(ptr, 1), count) + newPtr := add(ptr, 2) + } + default { + mstore8(ptr, 0x99) + mstore(add(ptr, 1), shl(240, count)) + newPtr := add(ptr, 3) + } + } + } + + let n := mload(shares) + let fmp := mload(0x40) + mstore(fmp, SET_SHARES) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [id, [[walletBytes, share]...]] + let p := add(fmp, 0xe0) + mstore8(p, 0x82) + p := add(p, 1) + p := writeCborUint64(p, id) + + p := writeCborArrayHeader(p, n) + let data := add(shares, 0x20) + for { let i := 0 } lt(i, n) { i := add(i, 1) } { + let s := mload(add(data, mul(i, 0x20))) // pointer to Share struct + let wallet := mload(s) + let share := mload(add(s, 0x20)) + mstore8(p, 0x82) + p := add(p, 1) + mstore(p, or(shl(232, 0x56040a), shl(72, wallet))) + p := add(p, 23) + p := writeCborUint64(p, share) + } + + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + if and(gt(returndatasize(), 0x1f), delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20)) { + exitCode := mload(0) + } + } + } + + /// @notice Calls SetShares on f02, reverting on actor error. + function setShares(uint64 id, Share[] memory shares) internal { + int256 exitCode = trySetShares(id, shares); + require(exitCode == EXIT_SUCCESS, SetSharesFailed(exitCode)); + } + + // ------------------------------------------------------------------------- + // Claim -- permissionless + // ------------------------------------------------------------------------- + + /// @notice Calls Claim on f02 without reverting on actor error. + /// @param id The stream (or drained-but-still-owing tombstone) to claim from + /// @param wallets The wallets to pay out; a wallet with nothing owed pays zero, not an error + /// @return exitCode 0 on success; a nonzero actor exit code (or the -1 precompile-failure + /// sentinel) otherwise + /// @return amounts Each wallet's paid entitlement (attoFIL), same order as `wallets`; empty + /// unless exitCode is 0 + /// @dev Builds params in fmp scratch (dead after the call), then -- only on success -- copies + /// the CBOR return payload into that same dead scratch and decodes `amounts` right after it, + /// so the two never overlap; only `amounts` itself is a real, pointer-bumped allocation. + function tryClaim(uint64 id, address[] memory wallets) + internal + returns (int256 exitCode, uint256[] memory amounts) + { + assembly ("memory-safe") { + function writeCborUint64(ptr, v) -> newPtr { + if lt(v, 24) { + mstore8(ptr, v) + newPtr := add(ptr, 1) + leave + } + if lt(v, 0x100) { + mstore(ptr, shl(240, or(0x1800, v))) + newPtr := add(ptr, 2) + leave + } + if lt(v, 0x10000) { + mstore(ptr, shl(232, or(0x190000, v))) + newPtr := add(ptr, 3) + leave + } + if lt(v, 0x100000000) { + mstore(ptr, shl(216, or(0x1a00000000, v))) + newPtr := add(ptr, 5) + leave + } + mstore(ptr, or(shl(248, 0x1b), shl(184, v))) + newPtr := add(ptr, 9) + } + + function writeCborArrayHeader(ptr, count) -> newPtr { + switch lt(count, 24) + case 1 { + mstore8(ptr, or(0x80, count)) + newPtr := add(ptr, 1) + } + default { + switch lt(count, 0x100) + case 1 { + mstore8(ptr, 0x98) + mstore8(add(ptr, 1), count) + newPtr := add(ptr, 2) + } + default { + mstore8(ptr, 0x99) + mstore(add(ptr, 1), shl(240, count)) + newPtr := add(ptr, 3) + } + } + } + + // Reads one CBOR head (major type + info-derived count/length), 1-, 2-, or 3-byte form. + function readCborHead(ptr) -> major, value, newPtr { + let b := byte(0, mload(ptr)) + major := shr(5, b) + let info := and(b, 0x1f) + switch lt(info, 24) + case 1 { + value := info + newPtr := add(ptr, 1) + } + default { + switch eq(info, 24) + case 1 { + value := byte(0, mload(add(ptr, 1))) + newPtr := add(ptr, 2) + } + default { + value := shr(240, mload(add(ptr, 1))) + newPtr := add(ptr, 3) + } + } + } + + let n := mload(wallets) + let fmp := mload(0x40) + mstore(fmp, CLAIM) + mstore(add(fmp, 0x20), 0) + mstore(add(fmp, 0x40), NO_FLAGS) + mstore(add(fmp, 0x60), CBOR_CODEC) + mstore(add(fmp, 0x80), 0xc0) + mstore(add(fmp, 0xa0), REWARD_ACTOR_ID) + + // Params CBOR: [id, [walletBytes...]] + let p := add(fmp, 0xe0) + mstore8(p, 0x82) + p := add(p, 1) + p := writeCborUint64(p, id) + p := writeCborArrayHeader(p, n) + let wdata := add(wallets, 0x20) + for { let i := 0 } lt(i, n) { i := add(i, 1) } { + let wallet := mload(add(wdata, mul(i, 0x20))) + mstore(p, or(shl(232, 0x56040a), shl(72, wallet))) + p := add(p, 23) + } + mstore(add(fmp, 0xc0), sub(p, add(fmp, 0xe0))) + + exitCode := not(0) + let callOk := delegatecall(gas(), CALL_ACTOR_BY_ID, fmp, sub(p, fmp), 0, 0x20) + + amounts := mload(0x40) // empty by default; only reassigned below on a decoded success + mstore(amounts, 0) + + if and(callOk, gt(returndatasize(), 0x1f)) { + exitCode := mload(0) + // returndata layout: 0x00 exitCode, 0x20 codec, 0x40 bytes offset, 0x60 bytes + // length, 0x80+ the CBOR payload itself (a CBOR array of BigInt byte strings). + if and(iszero(exitCode), gt(returndatasize(), 0x80)) { + let cborLen := sub(returndatasize(), 0x80) + let cborPtr := fmp // call input is dead; reuse it as scratch for the raw copy + returndatacopy(cborPtr, 0x80, cborLen) + + let arrMajor, count, cur + arrMajor, count, cur := readCborHead(cborPtr) + + // Place the real, pointer-bumped array right after the raw scratch copy so + // the two never overlap. + amounts := add(cborPtr, cborLen) + mstore(amounts, count) + let out := add(amounts, 0x20) + + for { let i := 0 } lt(i, count) { i := add(i, 1) } { + let strMajor, blen + strMajor, blen, cur := readCborHead(cur) + let value := 0 + if gt(blen, 0) { + let signByte := byte(0, mload(cur)) + let magLen := sub(blen, 1) + if or(gt(signByte, 0), gt(magLen, 32)) { revert(0, 0) } + if gt(magLen, 0) { value := shr(mul(8, sub(32, magLen)), mload(add(cur, 1))) } + } + mstore(add(out, mul(i, 0x20)), value) + cur := add(cur, blen) + } + + mstore(0x40, add(out, mul(count, 0x20))) + } + } + } + } + + /// @notice Calls Claim on f02, reverting on actor error. + function claim(uint64 id, address[] memory wallets) internal returns (uint256[] memory amounts) { + int256 exitCode; + (exitCode, amounts) = tryClaim(id, wallets); + require(exitCode == EXIT_SUCCESS, ClaimFailed(exitCode)); + } +} diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 347c431..d4f4381 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.36; import {Vm} from "forge-std/Vm.sol"; import {USR_FORBIDDEN, USR_ILLEGAL_ARGUMENT, USR_NOT_FOUND, USR_UNHANDLED_MESSAGE} from "fvm-solidity/FVMErrors.sol"; +import {CBOR_CODEC} from "fvm-solidity/FVMCodec.sol"; import {FVMPay} from "fvm-solidity/FVMPay.sol"; import { @@ -17,7 +18,8 @@ import { CANCEL_PENDING, CLAIM, SWA_TIMELOCK -} from "./FVMRewardMethod.sol"; +} from "../../src/lib/FVMRewardMethod.sol"; +import {WeightRecord, DistributionKind, Share, PendingOp} from "../../src/lib/FVMRewardTypes.sol"; /// @dev Weights, and per-orchestrator shares, are WAD-scaled: 1e18 == 1.0 == 100%. int256 constant WAD = 1e18; @@ -29,40 +31,11 @@ uint256 constant MAX_RECIPIENTS = 64; /// @dev Same value as WAD, typed uint256, so summing shares needs no signed-to-unsigned cast. uint256 constant SHARE_TOTAL = 1e18; -/// @notice Per-stream weight schedule; WAD-scaled, slope may be negative. -struct WeightRecord { - int256 vStart; - int256 slope; - uint64 tStart; - int256 floor; - int256 cap; -} - -/// @notice IMPLICIT is the consensus stream (f02-resolved); EXPLICIT uses a writer-owned share map. -enum DistributionKind { - IMPLICIT, - EXPLICIT -} - -struct Share { - address wallet; - uint256 share; -} - struct LedgerRow { address wallet; uint256 amount; } -/// @notice The five queueable SWA write kinds; SetShares and Claim never queue. -enum PendingOp { - SET_WEIGHT, - STEP_WEIGHT, - REGISTER, - REMOVE, - SET_DISTRIBUTION -} - /// @dev Enumerable, prunable address->uint256 balance -- plain mappings-plus-array, not the /// builtin-actor's CBOR-behind-a-CID shape (fine: nothing implements that wire format yet). struct Ledger { @@ -280,7 +253,8 @@ contract FVMRewardActor { function _queueWeightWrite(PendingOp op, bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); - (uint64[] memory ids, WeightRecord[] memory records) = abi.decode(params, (uint64[], WeightRecord[])); + // Params CBOR: [[id...], [[vStart,slope,tStart,floor,cap]...]] + (uint64[] memory ids, WeightRecord[] memory records) = _decodeSetWeightRecordsParams(params); if (ids.length != records.length) return (USR_ILLEGAL_ARGUMENT, 0, ""); uint64 effectiveEpoch = uint64(block.number) + swaTimelockEpochs; @@ -321,7 +295,8 @@ contract FVMRewardActor { // ------------------------------------------------------------------------- function _setShares(bytes calldata params) internal returns (uint32, uint64, bytes memory) { - (uint64 id, Share[] memory newShares) = abi.decode(params, (uint64, Share[])); + // Params CBOR: [id, [[walletBytes, share]...]] + (uint64 id, Share[] memory newShares) = _decodeSetSharesParams(params); Stream storage s = _streams[id]; if (!s.exists) return (USR_NOT_FOUND, 0, ""); if (s.kind != DistributionKind.EXPLICIT) return (USR_ILLEGAL_ARGUMENT, 0, ""); @@ -401,8 +376,9 @@ contract FVMRewardActor { function _registerStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); + // Params CBOR: [id, [vStart,slope,tStart,floor,cap], kind, writerOrNull, activationEpoch] (uint64 id, WeightRecord memory record, DistributionKind kind, address writer, uint64 activationEpoch) = - abi.decode(params, (uint64, WeightRecord, DistributionKind, address, uint64)); + _decodeRegisterStreamParams(params); // Rejects any id collision it can see: a live stream, an undrained tombstone, or an // already-queued registration. Reuse after a tombstone fully drains is SWA discipline. @@ -437,7 +413,8 @@ contract FVMRewardActor { function _removeStream(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); - uint64 id = abi.decode(params, (uint64)); + // Params CBOR: a bare uint64 (streamId), no array wrapper + uint64 id = _decodeBareUint64(params); if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); if (_pendingExists[id][PendingOp.REMOVE]) return (USR_ILLEGAL_ARGUMENT, 0, ""); @@ -460,7 +437,8 @@ contract FVMRewardActor { function _setDistribution(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); - (uint64 id, DistributionKind kind, address writer) = abi.decode(params, (uint64, DistributionKind, address)); + // Params CBOR: [id, kind, writerOrNull] + (uint64 id, DistributionKind kind, address writer) = _decodeSetDistributionParams(params); if (!_streams[id].exists) return (USR_NOT_FOUND, 0, ""); // Converting a stream to IMPLICIT is not permitted (IMPLICIT is consensus-only). if (kind == DistributionKind.IMPLICIT || writer == address(0)) return (USR_ILLEGAL_ARGUMENT, 0, ""); @@ -485,7 +463,8 @@ contract FVMRewardActor { function _cancelPending(bytes calldata params) internal returns (uint32, uint64, bytes memory) { if (msg.sender != swa) return (USR_FORBIDDEN, 0, ""); - (uint64 id, PendingOp op) = abi.decode(params, (uint64, PendingOp)); + // Params CBOR: [id, op] + (uint64 id, PendingOp op) = _decodeCancelPendingParams(params); if (_pendingExists[id][op]) { delete _pending[id][op]; _pendingExists[id][op] = false; @@ -501,7 +480,8 @@ contract FVMRewardActor { // ------------------------------------------------------------------------- function _claim(bytes calldata params) internal returns (uint32, uint64, bytes memory) { - (uint64 id, address[] memory wallets) = abi.decode(params, (uint64, address[])); + // Params CBOR: [id, [walletBytes...]] + (uint64 id, address[] memory wallets) = _decodeClaimParams(params); bool tombstoned = _tombstones[id].exists; Stream storage s = _streams[id]; @@ -537,7 +517,281 @@ contract FVMRewardActor { emit Claimed(id, wallet, entitlement); amounts[i] = entitlement; } - return (0, 0, abi.encode(amounts)); + // Return CBOR: an array of Filecoin BigInt-encoded entitlements, one per wallet. + return (0, CBOR_CODEC, _encodeCborBigIntArray(amounts)); + } + + // ------------------------------------------------------------------------- + // CBOR params/return encoding -- f02's real wire format; not gas-optimized, since only + // FVMRewards (src/lib/FVMRewards.sol) and this mock need to agree on it. + // ------------------------------------------------------------------------- + + // Every decode helper below takes/returns an absolute calldata byte position (not an offset + // into `params`), read via `calldataload` -- one word load per field, no `bytes calldata` + // indexing or intermediate slicing. + + function _decodeSetWeightRecordsParams(bytes calldata params) + private + pure + returns (uint64[] memory ids, WeightRecord[] memory records) + { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 2-element array header + uint256 idsCount; + (idsCount, pos) = _decodeCborArrayHeader(pos); + ids = new uint64[](idsCount); + for (uint256 i = 0; i < idsCount; i++) { + (ids[i], pos) = _decodeCborUint64(pos); + } + + uint256 recCount; + (recCount, pos) = _decodeCborArrayHeader(pos); + records = new WeightRecord[](recCount); + for (uint256 i = 0; i < recCount; i++) { + pos += 1; // skip the per-record 5-element array header + (records[i], pos) = _decodeWeightRecord(pos); + } + } + + function _decodeSetSharesParams(bytes calldata params) private pure returns (uint64 id, Share[] memory newShares) { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 2-element array header + (id, pos) = _decodeCborUint64(pos); + uint256 count; + (count, pos) = _decodeCborArrayHeader(pos); + newShares = new Share[](count); + for (uint256 i = 0; i < count; i++) { + pos += 1; // skip the per-entry 2-element array header + address wallet; + (wallet, pos) = _decodeAddressOrNull(pos); + uint64 share; + (share, pos) = _decodeCborUint64(pos); + newShares[i] = Share({wallet: wallet, share: share}); + } + } + + function _decodeRegisterStreamParams(bytes calldata params) + private + pure + returns (uint64 id, WeightRecord memory record, DistributionKind kind, address writer, uint64 activationEpoch) + { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 5-element array header + (id, pos) = _decodeCborUint64(pos); + pos += 1; // skip the weight-record 5-element array header + (record, pos) = _decodeWeightRecord(pos); + uint64 kindOrdinal; + (kindOrdinal, pos) = _decodeCborUint64(pos); + kind = DistributionKind(kindOrdinal); + (writer, pos) = _decodeAddressOrNull(pos); + (activationEpoch, pos) = _decodeCborUint64(pos); + } + + function _decodeSetDistributionParams(bytes calldata params) + private + pure + returns (uint64 id, DistributionKind kind, address writer) + { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 3-element array header + (id, pos) = _decodeCborUint64(pos); + uint64 kindOrdinal; + (kindOrdinal, pos) = _decodeCborUint64(pos); + kind = DistributionKind(kindOrdinal); + (writer, pos) = _decodeAddressOrNull(pos); + } + + function _decodeCancelPendingParams(bytes calldata params) private pure returns (uint64 id, PendingOp op) { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 2-element array header + (id, pos) = _decodeCborUint64(pos); + uint64 opOrdinal; + (opOrdinal, pos) = _decodeCborUint64(pos); + op = PendingOp(opOrdinal); + } + + function _decodeClaimParams(bytes calldata params) private pure returns (uint64 id, address[] memory wallets) { + uint256 pos = _calldataPos(params) + 1; // skip the top-level 2-element array header + (id, pos) = _decodeCborUint64(pos); + uint256 count; + (count, pos) = _decodeCborArrayHeader(pos); + wallets = new address[](count); + for (uint256 i = 0; i < count; i++) { + (wallets[i], pos) = _decodeAddressOrNull(pos); + } + } + + /// @dev Bare CBOR uint64 (no array wrapper), e.g. RemoveStream's single streamId param. + function _decodeBareUint64(bytes calldata params) private pure returns (uint64 v) { + (v,) = _decodeCborUint64(_calldataPos(params)); + } + + function _decodeWeightRecord(uint256 pos) private pure returns (WeightRecord memory record, uint256 newPos) { + int256 vStart; + int256 slope; + uint64 tStart; + int256 floor; + int256 cap; + (vStart, pos) = _decodeCborInt64(pos); + (slope, pos) = _decodeCborInt64(pos); + (tStart, pos) = _decodeCborUint64(pos); + (floor, pos) = _decodeCborInt64(pos); + (cap, pos) = _decodeCborInt64(pos); + record = WeightRecord({vStart: vStart, slope: slope, tStart: tStart, floor: floor, cap: cap}); + newPos = pos; + } + + /// @dev The absolute calldata byte position of a calldata bytes value's content. + function _calldataPos(bytes calldata data) private pure returns (uint256 pos) { + assembly ("memory-safe") { + pos := data.offset + } + } + + /// @dev Decodes a CBOR unsigned integer (major type 0) at absolute calldata position `pos`; + /// also reused for array-length header counts (major type 4), since both encode their value + /// the same way in the low 5 info bits. One `calldataload`, then shifts extract the width + /// the info byte calls for -- no per-byte reads. + function _decodeCborUint64(uint256 pos) private pure returns (uint64 v, uint256 newPos) { + assembly ("memory-safe") { + let w := calldataload(pos) + let info := and(byte(0, w), 0x1f) + let data := shl(8, w) // drop the header byte; field bytes now sit at the MSB end + switch lt(info, 24) + case 1 { + v := info + newPos := add(pos, 1) + } + default { + switch info + case 24 { + v := shr(248, data) + newPos := add(pos, 2) + } + case 25 { + v := shr(240, data) + newPos := add(pos, 3) + } + case 26 { + v := shr(224, data) + newPos := add(pos, 5) + } + default { + // info == 27 + v := shr(192, data) + newPos := add(pos, 9) + } + } + } + } + + function _decodeCborArrayHeader(uint256 pos) private pure returns (uint256 count, uint256 newPos) { + (uint64 c, uint256 np) = _decodeCborUint64(pos); + return (c, np); + } + + /// @dev Decodes a CBOR signed integer (major type 0 or 1) at absolute calldata position + /// `pos`; the value fits int256 regardless of major type, since a CBOR-major-1 int64's + /// magnitude is itself at most a uint64. + function _decodeCborInt64(uint256 pos) private pure returns (int256 v, uint256 newPos) { + uint256 major; + assembly ("memory-safe") { + major := shr(5, byte(0, calldataload(pos))) + } + uint64 magnitude; + (magnitude, newPos) = _decodeCborUint64(pos); + v = major == 0 ? int256(uint256(magnitude)) : -1 - int256(uint256(magnitude)); + } + + /// @dev Decodes an f410 delegated address wrapped in a CBOR byte string (0x04, 0x0a, 20 + /// bytes) at absolute calldata position `pos`, or CBOR null (address(0)) for an IMPLICIT + /// stream's absent writer. The address bytes are big-endian, so one `calldataload` shifted + /// into place reads all 20 at once. + function _decodeAddressOrNull(uint256 pos) private pure returns (address addr, uint256 newPos) { + assembly ("memory-safe") { + let b := byte(0, calldataload(pos)) + switch eq(b, 0xf6) + case 1 { + addr := 0 + newPos := add(pos, 1) + } + default { + let len := and(b, 0x1f) // 22 for f410; length is always inline + // header byte + [0x04, 0x0a] prefix (3 bytes), then 20 big-endian address bytes + addr := shr(96, calldataload(add(pos, 3))) + newPos := add(pos, add(1, len)) + } + } + } + + function _encodeCborArrayHeaderLen(uint256 count) private pure returns (uint256) { + if (count < 24) return 1; + if (count < 0x100) return 2; + return 3; + } + + /// @dev Writes a CBOR array(count) header into `out` starting at `pos`; returns the new position. + function _writeCborArrayHeader(bytes memory out, uint256 pos, uint256 count) private pure returns (uint256) { + if (count < 24) { + out[pos] = bytes1(uint8(0x80 | count)); + return pos + 1; + } + if (count < 0x100) { + out[pos] = bytes1(uint8(0x98)); + out[pos + 1] = bytes1(uint8(count)); + return pos + 2; + } + out[pos] = bytes1(uint8(0x99)); + out[pos + 1] = bytes1(uint8(count >> 8)); + out[pos + 2] = bytes1(uint8(count)); + return pos + 3; + } + + /// @dev The minimal big-endian encoding length of `value` (no leading zero byte); `value` is nonzero. + function _bigEndianLen(uint256 value) private pure returns (uint256 len) { + len = 32; + bytes32 full = bytes32(value); + while (full[32 - len] == 0) { + len--; + } + } + + /// @dev A Filecoin BigInt: a CBOR byte string containing a sign byte (0x00, since + /// entitlements are never negative) followed by the minimal big-endian magnitude; zero is + /// the empty byte string, matching go-state-types' big.Int (de)serialization. + function _bigIntEncodedLen(uint256 value) private pure returns (uint256) { + if (value == 0) return 1; + uint256 contentLen = _bigEndianLen(value) + 1; + return (contentLen < 24 ? 1 : 2) + contentLen; + } + + /// @dev Writes `value`'s BigInt CBOR encoding into `out` starting at `pos`; returns the new position. + function _writeCborBigInt(bytes memory out, uint256 pos, uint256 value) private pure returns (uint256) { + if (value == 0) { + out[pos] = bytes1(uint8(0x40)); + return pos + 1; + } + uint256 magLen = _bigEndianLen(value); + uint256 contentLen = magLen + 1; + if (contentLen < 24) { + out[pos++] = bytes1(uint8(0x40 | contentLen)); + } else { + out[pos++] = bytes1(uint8(0x58)); + out[pos++] = bytes1(uint8(contentLen)); + } + out[pos++] = 0x00; // sign byte: positive + bytes32 full = bytes32(value); + for (uint256 i = 0; i < magLen; i++) { + out[pos++] = full[32 - magLen + i]; + } + return pos; + } + + function _encodeCborBigIntArray(uint256[] memory values) private pure returns (bytes memory out) { + uint256 totalLen = _encodeCborArrayHeaderLen(values.length); + for (uint256 i = 0; i < values.length; i++) { + totalLen += _bigIntEncodedLen(values[i]); + } + out = new bytes(totalLen); + uint256 pos = _writeCborArrayHeader(out, 0, values.length); + for (uint256 i = 0; i < values.length; i++) { + pos = _writeCborBigInt(out, pos, values[i]); + } } // ------------------------------------------------------------------------- diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 97848ef..4f152e2 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -23,21 +23,14 @@ import { MAX_RECIPIENTS, SHARE_TOTAL } from "./FVMRewardActor.sol"; -import { - SET_WEIGHT_RECORDS, - STEP_WEIGHT_RECORDS, - SET_SHARES, - GET_STATE, - REGISTER_STREAM, - REMOVE_STREAM, - SET_DISTRIBUTION, - CANCEL_PENDING, - CLAIM, - SWA_TIMELOCK -} from "./FVMRewardMethod.sol"; +import {STEP_WEIGHT_RECORDS, GET_STATE, SWA_TIMELOCK} from "../../src/lib/FVMRewardMethod.sol"; +import {FVMRewards} from "../../src/lib/FVMRewards.sol"; /// @dev A distinct external caller, so tests can check authorization by identity rather than -/// by happenstance of who the test contract is. +/// by happenstance of who the test contract is. The typed methods below go through FVMRewards +/// itself (production code, not a hand-rolled duplicate encoder) so these tests double as +/// FVMRewards<->mock wire-format coverage; `call` remains for methods FVMRewards doesn't cover +/// yet (StepWeightRecords, GetState) and for tests that need to send malformed params on purpose. contract RewardCaller { function call(uint64 method, bytes memory params) external returns (uint32 exitCode, bytes memory data) { bytes memory callData = abi.encode(method, uint256(0), NO_FLAGS, uint64(0), params, REWARD_ACTOR_ID); @@ -45,6 +38,42 @@ contract RewardCaller { require(success, "RewardCaller: precompile call failed"); (exitCode,, data) = abi.decode(ret, (uint32, uint64, bytes)); } + + function registerStream( + uint64 id, + WeightRecord memory record, + DistributionKind kind, + address writer, + uint64 activationEpoch + ) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.tryRegisterStream(id, record, kind, writer, activationEpoch))); + } + + function removeStream(uint64 id) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.tryRemoveStream(id))); + } + + function setWeightRecords(uint64[] memory ids, WeightRecord[] memory records) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.trySetWeightRecords(ids, records))); + } + + function setDistribution(uint64 id, DistributionKind kind, address writer) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.trySetDistribution(id, kind, writer))); + } + + function cancelPending(uint64 id, PendingOp op) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.tryCancelPending(id, op))); + } + + function setShares(uint64 id, Share[] memory shares) external returns (uint32 exitCode) { + exitCode = uint32(uint256(FVMRewards.trySetShares(id, shares))); + } + + function claim(uint64 id, address[] memory wallets) external returns (uint32 exitCode, uint256[] memory amounts) { + int256 rawExitCode; + (rawExitCode, amounts) = FVMRewards.tryClaim(id, wallets); + exitCode = uint32(uint256(rawExitCode)); + } } contract FVMRewardActorTest is MockRewardTest { @@ -94,20 +123,28 @@ contract FVMRewardActorTest is MockRewardTest { return _record(w, 0, 0, 0, WAD); } - function _registerParams(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) + function _registerStream(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) internal - view - returns (bytes memory) + returns (uint32) { - return abi.encode(id, record, kind, writer, uint64(block.number) + SWA_TIMELOCK); + return swaCaller.registerStream(id, record, kind, writer, uint64(block.number) + SWA_TIMELOCK); } - function _registerStream(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) + /// @dev Bundles a single id/record into the arrays SetWeightRecords batches over. + function _singleWeightRecord(uint64 id, WeightRecord memory record) internal - returns (uint32) + pure + returns (uint64[] memory ids, WeightRecord[] memory records) { - (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, _registerParams(id, record, kind, writer)); - return exitCode; + ids = new uint64[](1); + ids[0] = id; + records = new WeightRecord[](1); + records[0] = record; + } + + function _setWeightRecords(RewardCaller caller, uint64 id, WeightRecord memory record) internal returns (uint32) { + (uint64[] memory ids, WeightRecord[] memory records) = _singleWeightRecord(id, record); + return caller.setWeightRecords(ids, records); } function _registerExplicit(uint64 id, address writer) internal { @@ -170,23 +207,17 @@ contract FVMRewardActorTest is MockRewardTest { arr[0] = Share({wallet: wallet, share: amount}); } - function _setSharesParams(uint64 id, Share[] memory shares_) internal pure returns (bytes memory) { - return abi.encode(id, shares_); - } - function _wallets(address a) internal pure returns (address[] memory arr) { arr = new address[](1); arr[0] = a; } - function _claimParams(uint64 id, address[] memory wallets_) internal pure returns (bytes memory) { - return abi.encode(id, wallets_); - } - + /// @dev Claim is permissionless, so this goes straight through FVMRewards rather than a + /// RewardCaller identity. function _claim(uint64 id, address[] memory wallets_) internal returns (uint32 exitCode, uint256[] memory amounts) { - bytes memory data; - (exitCode, data) = _call(CLAIM, _claimParams(id, wallets_)); - if (exitCode == 0) amounts = abi.decode(data, (uint256[])); + int256 rawExitCode; + (rawExitCode, amounts) = FVMRewards.tryClaim(id, wallets_); + exitCode = uint32(uint256(rawExitCode)); } function _payableRow(LedgerRow[] memory rows, address wallet) internal pure returns (uint256) { @@ -208,11 +239,11 @@ contract FVMRewardActorTest is MockRewardTest { function test_MockSwaTimelockEpochs_Overrides() public { rewardActor().mockSwaTimelockEpochs(10); - // _registerParams hardcodes SWA_TIMELOCK, not the override, so build params directly. - bytes memory params = abi.encode( + // _registerStream hardcodes SWA_TIMELOCK, not the override, so call directly with a + // 10-epoch activation instead. + uint32 exitCode = swaCaller.registerStream( SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0), uint64(block.number) + 10 ); - (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, params); assertEq(exitCode, 0); vm.roll(block.number + 10); @@ -277,21 +308,24 @@ contract FVMRewardActorTest is MockRewardTest { } function test_RegisterStream_NotSwa_Forbidden() public { - (uint32 exitCode,) = randomCaller.call( - REGISTER_STREAM, _registerParams(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)) + uint32 exitCode = randomCaller.registerStream( + SERVICE_ID, + _constantRecord(0.1e18), + DistributionKind.IMPLICIT, + address(0), + uint64(block.number) + SWA_TIMELOCK ); assertEq(exitCode, USR_FORBIDDEN); } function test_RegisterStream_ActivationTooSoon_IllegalArgument() public { - bytes memory params = abi.encode( + uint32 exitCode = swaCaller.registerStream( SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0), uint64(block.number) + SWA_TIMELOCK - 1 ); - (uint32 exitCode,) = swaCaller.call(REGISTER_STREAM, params); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } @@ -363,12 +397,11 @@ contract FVMRewardActorTest is MockRewardTest { function test_RegisterStream_TombstonedId_IllegalArgument() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setSharesExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setSharesExit, 0); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed - (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); assertEq(removeExit, 0); _warpPastTimelockAndSettle(); @@ -385,29 +418,107 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); } - function _setWeightParams(uint64 id, WeightRecord memory record) internal pure returns (bytes memory) { - uint64[] memory ids = new uint64[](1); - ids[0] = id; - WeightRecord[] memory records = new WeightRecord[](1); - records[0] = record; - return abi.encode(ids, records); + // ------------------------------------------------------------------------- + // StepWeightRecords params: FVMRewards has no encoder for this method (out of scope, see + // src/lib/FVMRewardMethod.sol), but the mock's decode is shared with SetWeightRecords and + // expects the same CBOR shape ([[id...], [[vStart,slope,tStart,floor,cap]...]]), so these + // tests build it by hand rather than via abi.encode. Only ever exercised with a single + // (id, record) pair, so the array headers are hardcoded to 0x81 (length 1). + // ------------------------------------------------------------------------- + + function _cborUint64Len(uint256 v) internal pure returns (uint256) { + if (v < 24) return 1; + if (v < 0x100) return 2; + if (v < 0x10000) return 3; + if (v < 0x100000000) return 5; + return 9; + } + + function _writeCborUint64(bytes memory out, uint256 pos, uint256 v) internal pure returns (uint256) { + if (v < 24) { + out[pos] = bytes1(uint8(v)); + return pos + 1; + } + if (v < 0x100) { + out[pos] = 0x18; + out[pos + 1] = bytes1(uint8(v)); + return pos + 2; + } + if (v < 0x10000) { + out[pos] = 0x19; + out[pos + 1] = bytes1(uint8(v >> 8)); + out[pos + 2] = bytes1(uint8(v)); + return pos + 3; + } + if (v < 0x100000000) { + out[pos] = 0x1a; + out[pos + 1] = bytes1(uint8(v >> 24)); + out[pos + 2] = bytes1(uint8(v >> 16)); + out[pos + 3] = bytes1(uint8(v >> 8)); + out[pos + 4] = bytes1(uint8(v)); + return pos + 5; + } + out[pos] = 0x1b; + for (uint256 i = 0; i < 8; i++) { + out[pos + 1 + i] = bytes1(uint8(v >> (8 * (7 - i)))); + } + return pos + 9; + } + + function _cborInt64Len(int256 v) internal pure returns (uint256) { + return _cborUint64Len(v < 0 ? uint256(-1 - v) : uint256(v)); + } + + /// @dev Writes the unsigned magnitude, then ORs the major-1 (negative) bit into the header + /// byte already written at `pos` -- correct for every CBOR int64 header form (inline or + /// 2/3/5/9-byte), since the info bits are identical between major type 0 and 1. + function _writeCborInt64(bytes memory out, uint256 pos, int256 v) internal pure returns (uint256) { + if (v >= 0) return _writeCborUint64(out, pos, uint256(v)); + uint256 newPos = _writeCborUint64(out, pos, uint256(-1 - v)); + out[pos] = out[pos] | 0x20; + return newPos; + } + + function _cborRecordLen(WeightRecord memory r) internal pure returns (uint256) { + return 1 + _cborInt64Len(r.vStart) + _cborInt64Len(r.slope) + _cborUint64Len(r.tStart) + _cborInt64Len(r.floor) + + _cborInt64Len(r.cap); + } + + function _writeCborRecord(bytes memory out, uint256 pos, WeightRecord memory r) internal pure returns (uint256) { + out[pos++] = 0x85; + pos = _writeCborInt64(out, pos, r.vStart); + pos = _writeCborInt64(out, pos, r.slope); + pos = _writeCborUint64(out, pos, r.tStart); + pos = _writeCborInt64(out, pos, r.floor); + pos = _writeCborInt64(out, pos, r.cap); + return pos; + } + + function _setWeightParams(uint64 id, WeightRecord memory record) internal pure returns (bytes memory out) { + uint256 len = 2 + _cborUint64Len(id) + 1 + _cborRecordLen(record); + out = new bytes(len); + uint256 pos = 0; + out[pos++] = 0x82; // top-level 2-element array + out[pos++] = 0x81; // ids: 1-element array + pos = _writeCborUint64(out, pos, id); + out[pos++] = 0x81; // records: 1-element array + pos = _writeCborRecord(out, pos, record); } function test_SetWeightRecords_NotSwa_Forbidden() public { - (uint32 exitCode,) = - randomCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + uint32 exitCode = _setWeightRecords(randomCaller, SERVICE_ID, _constantRecord(0.1e18)); assertEq(exitCode, USR_FORBIDDEN); } function test_SetWeightRecords_NonexistentStream_NotFound() public { - (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + uint32 exitCode = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.1e18)); assertEq(exitCode, USR_NOT_FOUND); } function test_SetWeightRecords_MismatchedArrayLengths_IllegalArgument() public { uint64[] memory ids = new uint64[](2); WeightRecord[] memory records = new WeightRecord[](1); - (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, abi.encode(ids, records)); + uint32 exitCode = swaCaller.setWeightRecords(ids, records); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } @@ -422,7 +533,7 @@ contract FVMRewardActorTest is MockRewardTest { records[0] = _constantRecord(0.5e18); records[1] = _constantRecord(0.5e18); - (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, abi.encode(ids, records)); + uint32 exitCode = swaCaller.setWeightRecords(ids, records); assertEq(exitCode, USR_ILLEGAL_ARGUMENT, "a repeated id queues two PendingKeys for one slot"); } @@ -430,7 +541,7 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); WeightRecord memory bad = _record(0, 0, 0, 0.9e18, 0.1e18); // floor > cap - (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, bad)); + uint32 exitCode = _setWeightRecords(swaCaller, SERVICE_ID, bad); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } @@ -439,7 +550,7 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.3e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); - (uint32 exitCode,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.8e18))); + uint32 exitCode = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.8e18)); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } @@ -448,11 +559,10 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.4e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); - (uint32 firstExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.6e18))); + uint32 firstExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.6e18)); assertEq(firstExit, 0); - (uint32 secondExit,) = - swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(CONSENSUS_ID, _constantRecord(0.6e18))); + uint32 secondExit = _setWeightRecords(swaCaller, CONSENSUS_ID, _constantRecord(0.6e18)); assertEq( secondExit, USR_ILLEGAL_ARGUMENT, @@ -464,7 +574,7 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); - (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.7e18))); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.7e18)); assertEq(setExit, 0); StreamView[] memory before = _streams(); @@ -504,7 +614,7 @@ contract FVMRewardActorTest is MockRewardTest { // Both queue successfully: distinct (id, op) slots. (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.15e18))); assertEq(stepExit, 0); - (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.2e18))); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.2e18)); assertEq(setExit, 0); assertEq(_getState().pendingWrites.length, 2); @@ -524,32 +634,27 @@ contract FVMRewardActorTest is MockRewardTest { function test_SetShares_NotWriter_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 exitCode,) = - randomCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 exitCode = randomCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(exitCode, USR_FORBIDDEN); } function test_SetShares_NonexistentStream_NotFound() public { - (uint32 exitCode,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 exitCode = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(exitCode, USR_NOT_FOUND); } function test_SetShares_ImplicitStream_IllegalArgument() public { assertEq(_registerStream(CONSENSUS_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); _warpPastTimelockAndSettle(); - (uint32 exitCode,) = - randomCaller.call(SET_SHARES, _setSharesParams(CONSENSUS_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 exitCode = randomCaller.setShares(CONSENSUS_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } function test_SetShares_DoesNotSumToOne_IllegalArgument() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 under,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL - 1))); + uint32 under = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL - 1)); assertEq(under, USR_ILLEGAL_ARGUMENT); - (uint32 over,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL + 1))); + uint32 over = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL + 1)); assertEq(over, USR_ILLEGAL_ARGUMENT); } @@ -560,14 +665,14 @@ contract FVMRewardActorTest is MockRewardTest { for (uint256 i = 0; i < n; i++) { shares_[i] = Share({wallet: address(uint160(i + 1)), share: SHARE_TOTAL / n}); } - (uint32 exitCode,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, shares_)); + uint32 exitCode = writerCaller.setShares(SERVICE_ID, shares_); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } function test_SetShares_Valid_AppliesImmediately_NoTimelock() public { _registerExplicit(SERVICE_ID, address(writerCaller)); Share[] memory shares_ = _shares(RECIPIENT_A, SHARE_TOTAL); - (uint32 exitCode,) = writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, shares_)); + uint32 exitCode = writerCaller.setShares(SERVICE_ID, shares_); assertEq(exitCode, 0); // No vm.roll here: SetShares is the writer's own write, not queued under the timelock. @@ -579,15 +684,13 @@ contract FVMRewardActorTest is MockRewardTest { function test_SetShares_FoldsAccruedIntoPayable_AndBurnsResidue() public { _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 - (uint32 firstSetExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 firstSetExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(firstSetExit, 0); rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether uint256 burnBefore = BURN_ADDRESS.balance; - (uint32 exitCode,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL))); + uint32 exitCode = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL)); assertEq(exitCode, 0); // Sole recipient held 100%, so folding leaves no rounding residue. @@ -603,22 +706,21 @@ contract FVMRewardActorTest is MockRewardTest { function test_RemoveStream_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 exitCode,) = randomCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 exitCode = randomCaller.removeStream(SERVICE_ID); assertEq(exitCode, USR_FORBIDDEN); } function test_RemoveStream_Nonexistent_NotFound() public { - (uint32 exitCode,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 exitCode = swaCaller.removeStream(SERVICE_ID); assertEq(exitCode, USR_NOT_FOUND); } function test_RemoveStream_QueuedThenRemoved_AndSharesCleared() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setExit, 0); - (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); assertEq(removeExit, 0); assertEq(_streams().length, 1, "must still be live before the timelock elapses"); @@ -629,7 +731,7 @@ contract FVMRewardActorTest is MockRewardTest { function test_RemoveStream_NoOutstandingPayable_NoTombstoneCreated() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); assertEq(removeExit, 0); _warpPastTimelockAndSettle(); @@ -638,13 +740,12 @@ contract FVMRewardActorTest is MockRewardTest { function test_RemoveStream_OutstandingPayable_MovesToTombstone_ClaimableAfterRemoval() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setSharesExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setSharesExit, 0); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed - (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); assertEq(removeExit, 0); _warpPastTimelockAndSettle(); @@ -665,23 +766,20 @@ contract FVMRewardActorTest is MockRewardTest { function test_SetDistribution_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 exitCode,) = randomCaller.call( - SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) - ); + uint32 exitCode = + randomCaller.setDistribution(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)); assertEq(exitCode, USR_FORBIDDEN); } function test_SetDistribution_ToImplicit_IllegalArgument() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 exitCode,) = - swaCaller.call(SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.IMPLICIT, address(0))); + uint32 exitCode = swaCaller.setDistribution(SERVICE_ID, DistributionKind.IMPLICIT, address(0)); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } function test_SetDistribution_ZeroWriter_IllegalArgument() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 exitCode,) = - swaCaller.call(SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(0))); + uint32 exitCode = swaCaller.setDistribution(SERVICE_ID, DistributionKind.EXPLICIT, address(0)); assertEq(exitCode, USR_ILLEGAL_ARGUMENT); } @@ -689,46 +787,36 @@ contract FVMRewardActorTest is MockRewardTest { // overwrites it via SetShares, so payments continue across the transition. function test_SetDistribution_OldWriterStaysAuthorizedUntilTimelockElapses() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 initialSet,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 initialSet = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(initialSet, 0); - (uint32 distExit,) = swaCaller.call( - SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) - ); + uint32 distExit = swaCaller.setDistribution(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)); assertEq(distExit, 0); // Before the timelock elapses: old writer still authorized, new writer is not. - (uint32 oldWriterExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL))); + uint32 oldWriterExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL)); assertEq(oldWriterExit, 0); - (uint32 newWriterExitTooEarly,) = - otherWriterCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + uint32 newWriterExitTooEarly = otherWriterCaller.setShares(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL)); assertEq(newWriterExitTooEarly, USR_FORBIDDEN); _warpPastTimelockAndSettle(); // After the timelock elapses: roles have swapped. - (uint32 oldWriterExitTooLate,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + uint32 oldWriterExitTooLate = writerCaller.setShares(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL)); assertEq(oldWriterExitTooLate, USR_FORBIDDEN); - (uint32 newWriterExit,) = - otherWriterCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL))); + uint32 newWriterExit = otherWriterCaller.setShares(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL)); assertEq(newWriterExit, 0); assertEq(rewardActor().getShares(SERVICE_ID)[0].wallet, address(0xF00D)); } function test_SetDistribution_ApplyFoldsAccruedIntoPayable() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setSharesExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setSharesExit, 0); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues under RECIPIENT_A - (uint32 distExit,) = swaCaller.call( - SET_DISTRIBUTION, abi.encode(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)) - ); + uint32 distExit = swaCaller.setDistribution(SERVICE_ID, DistributionKind.EXPLICIT, address(otherWriterCaller)); assertEq(distExit, 0); _warpPastTimelockAndSettle(); @@ -740,24 +828,24 @@ contract FVMRewardActorTest is MockRewardTest { function test_CancelPending_NotSwa_Forbidden() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.9e18)); assertEq(setExit, 0); - (uint32 exitCode,) = randomCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + uint32 exitCode = randomCaller.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); assertEq(exitCode, USR_FORBIDDEN); } // Cancelling a slot with nothing queued must succeed as a no-op, not error. function test_CancelPending_EmptySlot_BenignNoOp() public { - (uint32 exitCode,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + uint32 exitCode = swaCaller.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); assertEq(exitCode, 0); } function test_CancelPending_DiscardsQueuedSetWeightRecords() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.9e18)); assertEq(setExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); @@ -766,7 +854,7 @@ contract FVMRewardActorTest is MockRewardTest { function test_CancelPending_DiscardsQueuedRegisterStream() public { assertEq(_registerStream(SERVICE_ID, _constantRecord(0.1e18), DistributionKind.IMPLICIT, address(0)), 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.REGISTER)); + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.REGISTER); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); @@ -775,10 +863,10 @@ contract FVMRewardActorTest is MockRewardTest { function test_CancelPending_DiscardsQueuedRemoveStream() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 removeExit,) = swaCaller.call(REMOVE_STREAM, abi.encode(SERVICE_ID)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); assertEq(removeExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.REMOVE)); + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.REMOVE); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); @@ -791,7 +879,7 @@ contract FVMRewardActorTest is MockRewardTest { (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.9e18))); assertEq(stepExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.STEP_WEIGHT)); + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.STEP_WEIGHT); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); @@ -800,12 +888,12 @@ contract FVMRewardActorTest is MockRewardTest { function test_CancelPending_OnlyCancelsMatchingOp() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setExit,) = swaCaller.call(SET_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.5e18))); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.5e18)); assertEq(setExit, 0); (uint32 stepExit,) = swaCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.6e18))); assertEq(stepExit, 0); - (uint32 cancelExit,) = swaCaller.call(CANCEL_PENDING, abi.encode(SERVICE_ID, PendingOp.SET_WEIGHT)); + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); assertEq(cancelExit, 0); _warpPastTimelockAndSettle(); @@ -827,8 +915,7 @@ contract FVMRewardActorTest is MockRewardTest { function test_Claim_UnknownWallet_ReturnsZero_NoRevert() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setSharesExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setSharesExit, 0); (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_B)); @@ -838,8 +925,7 @@ contract FVMRewardActorTest is MockRewardTest { function test_Claim_PaysLiveAccrual() public { _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 - (uint32 setSharesExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setSharesExit, 0); rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether @@ -858,15 +944,13 @@ contract FVMRewardActorTest is MockRewardTest { function test_Claim_PaysPayablePlusLive() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 firstSetExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 firstSetExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(firstSetExit, 0); rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed // Fold the period (via a new SetShares) so the 0.1 ether moves into payable. - (uint32 secondSetExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 secondSetExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(secondSetExit, 0); assertEq(_payableRow(rewardActor().getPayable(SERVICE_ID), RECIPIENT_A), 0.1 ether); @@ -914,8 +998,7 @@ contract FVMRewardActorTest is MockRewardTest { function test_AwardBlockReward_SelfIssues_ClaimPaysWithoutPreDeal() public { _registerExplicit(SERVICE_ID, address(writerCaller)); - (uint32 setExit,) = - writerCaller.call(SET_SHARES, _setSharesParams(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL))); + uint32 setExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); assertEq(setExit, 0); rewardActor().mockAwardBlockReward(1 ether); // no vm.deal beforehand From f13dd50fefdf8fd5e4db62864a21d466a6a77e05 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 18:59:03 -0500 Subject: [PATCH 10/14] perf(f02-mocks): encode Claim's CBOR bigint return via raw pointer writes Assisted-by: Claude:claude-sonnet-4-6 --- test/mocks/FVMRewardActor.sol | 124 ++++++++++++++++++++-------------- 1 file changed, 73 insertions(+), 51 deletions(-) diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index d4f4381..4380835 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -719,27 +719,32 @@ contract FVMRewardActor { } } - function _encodeCborArrayHeaderLen(uint256 count) private pure returns (uint256) { - if (count < 24) return 1; - if (count < 0x100) return 2; - return 3; - } - - /// @dev Writes a CBOR array(count) header into `out` starting at `pos`; returns the new position. - function _writeCborArrayHeader(bytes memory out, uint256 pos, uint256 count) private pure returns (uint256) { - if (count < 24) { - out[pos] = bytes1(uint8(0x80 | count)); - return pos + 1; - } - if (count < 0x100) { - out[pos] = bytes1(uint8(0x98)); - out[pos + 1] = bytes1(uint8(count)); - return pos + 2; + /// @dev Writes a CBOR array(count) header at absolute memory position `pos`; returns the new + /// position. `pos` is a raw pointer (like a calldata `.offset`), not an index into a `bytes + /// memory` -- callers compute it once from their buffer instead of passing the buffer itself, + /// so every write is a direct `mstore8` rather than a bounds-checked `bytes memory` index. + function _writeCborArrayHeader(uint256 pos, uint256 count) private pure returns (uint256 newPos) { + assembly ("memory-safe") { + switch lt(count, 24) + case 1 { + mstore8(pos, or(0x80, count)) + newPos := add(pos, 1) + } + default { + switch lt(count, 0x100) + case 1 { + mstore8(pos, 0x98) + mstore8(add(pos, 1), count) + newPos := add(pos, 2) + } + default { + mstore8(pos, 0x99) + mstore8(add(pos, 1), shr(8, count)) + mstore8(add(pos, 2), count) + newPos := add(pos, 3) + } + } } - out[pos] = bytes1(uint8(0x99)); - out[pos + 1] = bytes1(uint8(count >> 8)); - out[pos + 2] = bytes1(uint8(count)); - return pos + 3; } /// @dev The minimal big-endian encoding length of `value` (no leading zero byte); `value` is nonzero. @@ -751,46 +756,63 @@ contract FVMRewardActor { } } - /// @dev A Filecoin BigInt: a CBOR byte string containing a sign byte (0x00, since - /// entitlements are never negative) followed by the minimal big-endian magnitude; zero is - /// the empty byte string, matching go-state-types' big.Int (de)serialization. - function _bigIntEncodedLen(uint256 value) private pure returns (uint256) { - if (value == 0) return 1; - uint256 contentLen = _bigEndianLen(value) + 1; - return (contentLen < 24 ? 1 : 2) + contentLen; - } - - /// @dev Writes `value`'s BigInt CBOR encoding into `out` starting at `pos`; returns the new position. - function _writeCborBigInt(bytes memory out, uint256 pos, uint256 value) private pure returns (uint256) { + /// @dev Writes `value`'s Filecoin BigInt CBOR encoding (a CBOR byte string containing a sign + /// byte -- 0x00, since entitlements are never negative -- followed by the minimal big-endian + /// magnitude; zero is the empty byte string, matching go-state-types' big.Int + /// (de)serialization) at absolute memory position `pos`; returns the new position. + function _writeCborBigInt(uint256 pos, uint256 value) private pure returns (uint256 newPos) { if (value == 0) { - out[pos] = bytes1(uint8(0x40)); - return pos + 1; + assembly ("memory-safe") { + mstore8(pos, 0x40) + newPos := add(pos, 1) + } + return newPos; } uint256 magLen = _bigEndianLen(value); uint256 contentLen = magLen + 1; - if (contentLen < 24) { - out[pos++] = bytes1(uint8(0x40 | contentLen)); - } else { - out[pos++] = bytes1(uint8(0x58)); - out[pos++] = bytes1(uint8(contentLen)); - } - out[pos++] = 0x00; // sign byte: positive - bytes32 full = bytes32(value); - for (uint256 i = 0; i < magLen; i++) { - out[pos++] = full[32 - magLen + i]; + assembly ("memory-safe") { + let p := pos + switch lt(contentLen, 24) + case 1 { + mstore8(p, or(0x40, contentLen)) + p := add(p, 1) + } + default { + mstore8(p, 0x58) + mstore8(add(p, 1), contentLen) + p := add(p, 2) + } + mstore8(p, 0) // sign byte: positive + p := add(p, 1) + // Magnitude, big-endian, right-aligned in `value`; the mstore's trailing bytes past + // magLen spill into the buffer's over-allocated slack (see below) and are harmless. + mstore(p, shl(mul(8, sub(32, magLen)), value)) + newPos := add(p, magLen) } - return pos; } + /// @dev Claims the free memory pointer directly rather than `new bytes(...)`, since the exact + /// length isn't known until after writing: `new bytes(worstCase)` would permanently bump the + /// free pointer past memory this array never ends up using (memory is never freed), forcing + /// every later allocation in the call to sit -- and pay expansion gas -- past that unused + /// stretch regardless. Writing before the free pointer is moved, then moving it to the real + /// (32-rounded) size afterward, is the standard memory-safe manual-allocation pattern: only + /// memory at-or-past the free pointer at the time of each write is ever touched. function _encodeCborBigIntArray(uint256[] memory values) private pure returns (bytes memory out) { - uint256 totalLen = _encodeCborArrayHeaderLen(values.length); - for (uint256 i = 0; i < values.length; i++) { - totalLen += _bigIntEncodedLen(values[i]); + uint256 n = values.length; + uint256 dataStart; + assembly ("memory-safe") { + out := mload(0x40) + dataStart := add(out, 0x20) } - out = new bytes(totalLen); - uint256 pos = _writeCborArrayHeader(out, 0, values.length); - for (uint256 i = 0; i < values.length; i++) { - pos = _writeCborBigInt(out, pos, values[i]); + uint256 pos = _writeCborArrayHeader(dataStart, n); + for (uint256 i = 0; i < n; i++) { + pos = _writeCborBigInt(pos, values[i]); + } + uint256 actualLen = pos - dataStart; + assembly ("memory-safe") { + mstore(out, actualLen) + mstore(0x40, add(dataStart, and(add(actualLen, 0x1f), not(0x1f)))) } } From f6f152f61fe759aae7cc9f080e3f1866f8c6520f Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 19:19:47 -0500 Subject: [PATCH 11/14] perf: replace mul-by-power-of-two and gt/eq-zero with shl and truthy checks Assisted-by: Claude:claude-sonnet-4-6 --- src/lib/FVMRewards.sol | 18 +++++++++--------- test/mocks/FVMRewardActor.sol | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/FVMRewards.sol b/src/lib/FVMRewards.sol index 899dead..2544229 100644 --- a/src/lib/FVMRewards.sol +++ b/src/lib/FVMRewards.sol @@ -340,14 +340,14 @@ library FVMRewards { p := writeCborArrayHeader(p, idsCount) let idsData := add(ids, 0x20) for { let i := 0 } lt(i, idsCount) { i := add(i, 1) } { - p := writeCborUint64(p, mload(add(idsData, mul(i, 0x20)))) + p := writeCborUint64(p, mload(add(idsData, shl(5, i)))) } let recCount := mload(records) p := writeCborArrayHeader(p, recCount) let recData := add(records, 0x20) for { let i := 0 } lt(i, recCount) { i := add(i, 1) } { - let rec := mload(add(recData, mul(i, 0x20))) + let rec := mload(add(recData, shl(5, i))) mstore8(p, 0x85) p := add(p, 1) p := writeCborInt64(p, mload(rec)) @@ -588,7 +588,7 @@ library FVMRewards { p := writeCborArrayHeader(p, n) let data := add(shares, 0x20) for { let i := 0 } lt(i, n) { i := add(i, 1) } { - let s := mload(add(data, mul(i, 0x20))) // pointer to Share struct + let s := mload(add(data, shl(5, i))) // pointer to Share struct let wallet := mload(s) let share := mload(add(s, 0x20)) mstore8(p, 0x82) @@ -718,7 +718,7 @@ library FVMRewards { p := writeCborArrayHeader(p, n) let wdata := add(wallets, 0x20) for { let i := 0 } lt(i, n) { i := add(i, 1) } { - let wallet := mload(add(wdata, mul(i, 0x20))) + let wallet := mload(add(wdata, shl(5, i))) mstore(p, or(shl(232, 0x56040a), shl(72, wallet))) p := add(p, 23) } @@ -752,17 +752,17 @@ library FVMRewards { let strMajor, blen strMajor, blen, cur := readCborHead(cur) let value := 0 - if gt(blen, 0) { + if blen { let signByte := byte(0, mload(cur)) let magLen := sub(blen, 1) - if or(gt(signByte, 0), gt(magLen, 32)) { revert(0, 0) } - if gt(magLen, 0) { value := shr(mul(8, sub(32, magLen)), mload(add(cur, 1))) } + if or(signByte, gt(magLen, 32)) { revert(0, 0) } + if magLen { value := shr(shl(3, sub(32, magLen)), mload(add(cur, 1))) } } - mstore(add(out, mul(i, 0x20)), value) + mstore(add(out, shl(5, i)), value) cur := add(cur, blen) } - mstore(0x40, add(out, mul(count, 0x20))) + mstore(0x40, add(out, shl(5, count))) } } } diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index 4380835..fa5c4c6 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -786,7 +786,7 @@ contract FVMRewardActor { p := add(p, 1) // Magnitude, big-endian, right-aligned in `value`; the mstore's trailing bytes past // magLen spill into the buffer's over-allocated slack (see below) and are harmless. - mstore(p, shl(mul(8, sub(32, magLen)), value)) + mstore(p, shl(shl(3, sub(32, magLen)), value)) newPos := add(p, magLen) } } From 02e94f3bc7c79b6ec8c9c6b45d3c58eb1a8c54b3 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 19:49:19 -0500 Subject: [PATCH 12/14] perf: switch on raw values instead of a precomputed comparison Assisted-by: Claude:claude-sonnet-4-6 --- src/lib/FVMRewards.sol | 10 ++++++---- test/mocks/FVMRewardActor.sol | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/lib/FVMRewards.sol b/src/lib/FVMRewards.sol index 2544229..9ee3c53 100644 --- a/src/lib/FVMRewards.sol +++ b/src/lib/FVMRewards.sol @@ -139,8 +139,10 @@ library FVMRewards { // Null iff writer is the zero address -- independent of `kind`, so a caller mistake // (e.g. IMPLICIT with a nonzero writer) reaches the actor honestly instead of being // silently corrected here. - switch iszero(writer) - case 1 { + // case 0 on the raw value, not case 1 on a precomputed iszero(writer): one ISZERO + // instead of two comparisons. + switch writer + case 0 { mstore8(p, 0xf6) // CBOR null p := add(p, 1) } @@ -423,8 +425,8 @@ library FVMRewards { mstore8(p, kind) p := add(p, 1) // Null iff writer is the zero address -- independent of `kind` (see RegisterStream). - switch iszero(writer) - case 1 { + switch writer + case 0 { mstore8(p, 0xf6) p := add(p, 1) } diff --git a/test/mocks/FVMRewardActor.sol b/test/mocks/FVMRewardActor.sol index fa5c4c6..6b49383 100644 --- a/test/mocks/FVMRewardActor.sol +++ b/test/mocks/FVMRewardActor.sol @@ -705,8 +705,8 @@ contract FVMRewardActor { function _decodeAddressOrNull(uint256 pos) private pure returns (address addr, uint256 newPos) { assembly ("memory-safe") { let b := byte(0, calldataload(pos)) - switch eq(b, 0xf6) - case 1 { + switch b + case 0xf6 { addr := 0 newPos := add(pos, 1) } From 8772103de755138e7ab7e02e3501b393fb06f9bf Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 20:01:34 -0500 Subject: [PATCH 13/14] test(f02-mocks): cover Claim's multi-wallet amounts decode Assisted-by: Claude:claude-sonnet-4-6 --- test/mocks/FVMRewardActor.t.sol | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol index 4f152e2..7296aac 100644 --- a/test/mocks/FVMRewardActor.t.sol +++ b/test/mocks/FVMRewardActor.t.sol @@ -942,6 +942,34 @@ contract FVMRewardActorTest is MockRewardTest { assertEq(RECIPIENT_A.balance, 0.1 ether); } + function _wallets(address a, address b) internal pure returns (address[] memory arr) { + arr = new address[](2); + arr[0] = a; + arr[1] = b; + } + + // Every other Claim test only ever passes a single wallet, so FVMRewards.tryClaim's + // returndata-array decode loop (and its trailing free-memory-pointer bump) never runs past + // one iteration. This exercises count > 1, including the last element specifically. + function test_Claim_MultipleWallets_ReturnsAmountsInOrder() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 + Share[] memory shares_ = new Share[](2); + shares_[0] = Share({wallet: RECIPIENT_A, share: 0.6e18}); + shares_[1] = Share({wallet: RECIPIENT_B, share: 0.4e18}); + uint32 setSharesExit = writerCaller.setShares(SERVICE_ID, shares_); + assertEq(setSharesExit, 0); + + rewardActor().mockAwardBlockReward(1 ether); // service accrues 0.1 ether, split 60/40 + + (uint32 exitCode, uint256[] memory amounts) = _claim(SERVICE_ID, _wallets(RECIPIENT_A, RECIPIENT_B)); + assertEq(exitCode, 0); + assertEq(amounts.length, 2); + assertEq(amounts[0], 0.06 ether); + assertEq(amounts[1], 0.04 ether, "last element of the returned array"); + assertEq(RECIPIENT_A.balance, 0.06 ether); + assertEq(RECIPIENT_B.balance, 0.04 ether); + } + function test_Claim_PaysPayablePlusLive() public { _registerExplicit(SERVICE_ID, address(writerCaller)); uint32 firstSetExit = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); From 29c5acf737af2f4ab3915aa8452b4d1922b44eb4 Mon Sep 17 00:00:00 2001 From: William Morriss Date: Mon, 3 Aug 2026 20:07:27 -0500 Subject: [PATCH 14/14] perf: drop unused major return from readCborHead Assisted-by: Claude:claude-sonnet-4-6 --- src/lib/FVMRewards.sol | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/FVMRewards.sol b/src/lib/FVMRewards.sol index 9ee3c53..f5efcf2 100644 --- a/src/lib/FVMRewards.sol +++ b/src/lib/FVMRewards.sol @@ -681,18 +681,18 @@ library FVMRewards { } // Reads one CBOR head (major type + info-derived count/length), 1-, 2-, or 3-byte form. - function readCborHead(ptr) -> major, value, newPtr { - let b := byte(0, mload(ptr)) - major := shr(5, b) - let info := and(b, 0x1f) + // Major type is never consumed by either caller below (this codebase's own encoder + // only ever produces the shapes they expect), so it's not computed or returned. + function readCborHead(ptr) -> value, newPtr { + let info := and(byte(0, mload(ptr)), 0x1f) switch lt(info, 24) case 1 { value := info newPtr := add(ptr, 1) } default { - switch eq(info, 24) - case 1 { + switch info + case 24 { value := byte(0, mload(add(ptr, 1))) newPtr := add(ptr, 2) } @@ -741,8 +741,8 @@ library FVMRewards { let cborPtr := fmp // call input is dead; reuse it as scratch for the raw copy returndatacopy(cborPtr, 0x80, cborLen) - let arrMajor, count, cur - arrMajor, count, cur := readCborHead(cborPtr) + let count, cur + count, cur := readCborHead(cborPtr) // Place the real, pointer-bumped array right after the raw scratch copy so // the two never overlap. @@ -751,8 +751,8 @@ library FVMRewards { let out := add(amounts, 0x20) for { let i := 0 } lt(i, count) { i := add(i, 1) } { - let strMajor, blen - strMajor, blen, cur := readCborHead(cur) + let blen + blen, cur := readCborHead(cur) let value := 0 if blen { let signByte := byte(0, mload(cur))