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 c86f0dc..d5af01b 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": "77901a5a1ad835b74ad3b72f73a8412cfe491c57" } } -} \ 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/src/lib/FVMRewardMethod.sol b/src/lib/FVMRewardMethod.sol new file mode 100644 index 0000000..b533dec --- /dev/null +++ b/src/lib/FVMRewardMethod.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 OR MIT +pragma solidity ^0.8.36; + +// 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); 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..f5efcf2 --- /dev/null +++ b/src/lib/FVMRewards.sol @@ -0,0 +1,779 @@ +// 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. + // 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) + } + 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, 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, shl(5, i))) + 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 writer + case 0 { + 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, shl(5, i))) // 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. + // 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 info + case 24 { + 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, shl(5, i))) + 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 count, cur + 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 blen + blen, cur := readCborHead(cur) + let value := 0 + if blen { + let signByte := byte(0, mload(cur)) + let magLen := sub(blen, 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, shl(5, i)), value) + cur := add(cur, blen) + } + + mstore(0x40, add(out, shl(5, count))) + } + } + } + } + + /// @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/FVMCallActorByIdWithReward.sol b/test/mocks/FVMCallActorByIdWithReward.sol new file mode 100644 index 0000000..2a668ee --- /dev/null +++ b/test/mocks/FVMCallActorByIdWithReward.sol @@ -0,0 +1,72 @@ +// 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 {USR_ILLEGAL_ARGUMENT} from "fvm-solidity/FVMErrors.sol"; +import {FVMCallActorById} from "fvm-solidity/mocks/FVMCallActorById.sol"; + +import {FVMRewardActor} from "./FVMRewardActor.sol"; + +/// @notice Extends fvm-solidity's CALL_ACTOR_BY_ID mock with a 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, 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"); + 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)) + } + } + + (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..6b49383 --- /dev/null +++ b/test/mocks/FVMRewardActor.sol @@ -0,0 +1,1067 @@ +// 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 {CBOR_CODEC} from "fvm-solidity/FVMCodec.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, + CLAIM, + SWA_TIMELOCK +} 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; + +/// @dev Mock-only caps; f02 requires these limits to exist but never fixes their values. +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. +uint256 constant SHARE_TOTAL = 1e18; + +struct LedgerRow { + address wallet; + uint256 amount; +} + +/// @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; +} + +/// @notice A removed stream's outstanding liabilities; a drained tombstone deletes itself. +struct Tombstone { + bool exists; + Ledger payableLedger; +} + +/// @dev A queued SWA write. Keyed by (streamId, op); an occupied slot rejects, so revising a +/// pending write means cancel + requeue. +struct Pending { + 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 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 { + /// @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; + + /// @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 => 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_; + } + + 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 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++) { + 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 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") { + return(add(response, 0x20), mload(response)) + } + } + + /// @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 _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 == CLAIM) return _claim(params); + return (USR_UNHANDLED_MESSAGE, 0, ""); + } + + // ------------------------------------------------------------------------- + // SetWeightRecords / StepWeightRecords -- SWA only, queued under separate ops. + // ------------------------------------------------------------------------- + + function _queueWeightWrite(PendingOp op, 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]...]] + (uint64[] memory ids, WeightRecord[] memory records) = _decodeSetWeightRecordsParams(params); + if (ids.length != records.length) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + 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, ""); + // 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); + 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++) { + _queueWrite( + ids[i], + op, + Pending({ + effectiveEpoch: effectiveEpoch, + weightRecord: records[i], + distributionKind: DistributionKind.IMPLICIT, + writer: address(0) + }) + ); + } + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // 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) { + // 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, ""); + if (msg.sender != s.writer) return (USR_FORBIDDEN, 0, ""); + if (newShares.length > MAX_RECIPIENTS) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + uint256 total; + 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 < newShares.length; i++) { + s.shares.push(newShares[i]); + } + return (0, 0, ""); + } + + function _getState() internal view returns (uint32, uint64, bytes memory) { + uint64 nowEpoch = uint64(block.number); + + StreamView[] memory streams = new StreamView[](_streamIds.length); + for (uint256 i = 0; i < _streamIds.length; i++) { + uint64 id = _streamIds[i]; + Stream storage s = _streams[id]; + 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) + }); + } + + 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, ""); + // Params CBOR: [id, [vStart,slope,tStart,floor,cap], kind, writerOrNull, activationEpoch] + (uint64 id, WeightRecord memory record, DistributionKind kind, address writer, uint64 activationEpoch) = + _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. + if (_streams[id].exists || _tombstones[id].exists || _pendingExists[id][PendingOp.REGISTER]) { + return (USR_ILLEGAL_ARGUMENT, 0, ""); + } + // 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, ""); + } + 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) + swaTimelockEpochs) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + int256 sum = _sumWeightsExcluding(new uint64[](0), activationEpoch) + _clampWeight(record, activationEpoch); + if (sum > WAD) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + _queueWrite( + id, + PendingOp.REGISTER, + Pending({effectiveEpoch: activationEpoch, weightRecord: record, distributionKind: kind, writer: writer}) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // 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, ""); + // 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, ""); + + _queueWrite( + id, + PendingOp.REMOVE, + Pending({ + effectiveEpoch: uint64(block.number) + swaTimelockEpochs, + weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), + distributionKind: DistributionKind.IMPLICIT, + writer: address(0) + }) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // 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, ""); + // 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, ""); + if (_pendingExists[id][PendingOp.SET_DISTRIBUTION]) return (USR_ILLEGAL_ARGUMENT, 0, ""); + + _queueWrite( + id, + PendingOp.SET_DISTRIBUTION, + Pending({ + effectiveEpoch: uint64(block.number) + swaTimelockEpochs, + weightRecord: WeightRecord({vStart: 0, slope: 0, tStart: 0, floor: 0, cap: 0}), + distributionKind: kind, + writer: writer + }) + ); + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // 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, ""); + // Params CBOR: [id, op] + (uint64 id, PendingOp op) = _decodeCancelPendingParams(params); + if (_pendingExists[id][op]) { + delete _pending[id][op]; + _pendingExists[id][op] = false; + _removePendingKey(id, op); + _recomputeNextTransition(); + emit PendingCancelled(id, op); + } + return (0, 0, ""); + } + + // ------------------------------------------------------------------------- + // Claim -- permissionless, batched; zero-entitlement entries pay nothing, no revert. + // ------------------------------------------------------------------------- + + function _claim(bytes calldata params) internal returns (uint32, uint64, bytes memory) { + // Params CBOR: [id, [walletBytes...]] + (uint64 id, address[] memory wallets) = _decodeClaimParams(params); + + 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 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 b + case 0xf6 { + 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)) + } + } + } + + /// @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) + } + } + } + } + + /// @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 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) { + assembly ("memory-safe") { + mstore8(pos, 0x40) + newPos := add(pos, 1) + } + return newPos; + } + uint256 magLen = _bigEndianLen(value); + uint256 contentLen = magLen + 1; + 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(shl(3, sub(32, magLen)), value)) + newPos := add(p, magLen) + } + } + + /// @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 n = values.length; + uint256 dataStart; + assembly ("memory-safe") { + out := mload(0x40) + dataStart := add(out, 0x20) + } + 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)))) + } + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /// @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); + } + + /// @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`. + 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 += _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; + } + } + + function _pendingRegistrationCount() internal view returns (uint256 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) { + _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++) { + uint64 e = _pending[_pendingKeys[i].id][_pendingKeys[i].op].effectiveEpoch; + if (e < best) best = e; + } + nextTransitionEpoch = best; + } + + function _removeTombstoneId(uint64 id) internal { + for (uint256 i = 0; i < _tombstoneIds.length; i++) { + if (_tombstoneIds[i] == id) { + _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; + } + } + } + + 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); + 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; + } + } + 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, 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 (op == PendingOp.SET_WEIGHT || op == PendingOp.STEP_WEIGHT) { + _streams[id].weightRecord = p.weightRecord; + } 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]; + _removeStreamId(id); + } +} diff --git a/test/mocks/FVMRewardActor.t.sol b/test/mocks/FVMRewardActor.t.sol new file mode 100644 index 0000000..7296aac --- /dev/null +++ b/test/mocks/FVMRewardActor.t.sol @@ -0,0 +1,1087 @@ +// 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, + LedgerRow, + PendingOp, + StreamView, + TombstoneView, + PendingView, + WAD, + MAX_STREAMS, + MAX_RECIPIENTS, + SHARE_TOTAL +} from "./FVMRewardActor.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. 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); + (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)); + } + + 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 { + using FVMPay for uint64; + + RewardCaller swaCaller; + RewardCaller writerCaller; + RewardCaller otherWriterCaller; + RewardCaller randomCaller; + + 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(); + 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 _registerStream(uint64 id, WeightRecord memory record, DistributionKind kind, address writer) + internal + returns (uint32) + { + return swaCaller.registerStream(id, record, kind, writer, uint64(block.number) + SWA_TIMELOCK); + } + + /// @dev Bundles a single id/record into the arrays SetWeightRecords batches over. + function _singleWeightRecord(uint64 id, WeightRecord memory record) + internal + pure + returns (uint64[] memory ids, WeightRecord[] memory records) + { + 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 { + 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 + } + + /// @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); + // 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; + } + + function _shares(address wallet, uint256 amount) internal pure returns (Share[] memory arr) { + arr = new Share[](1); + arr[0] = Share({wallet: wallet, share: amount}); + } + + function _wallets(address a) internal pure returns (address[] memory arr) { + arr = new address[](1); + arr[0] = a; + } + + /// @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) { + int256 rawExitCode; + (rawExitCode, amounts) = FVMRewards.tryClaim(id, wallets_); + exitCode = uint32(uint256(rawExitCode)); + } + + 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); + + // _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 + ); + assertEq(exitCode, 0); + + vm.roll(block.number + 10); + assertEq(_streams().length, 1, "must settle after the overridden (short) hold"); + } + + // ------------------------------------------------------------------------- + // ClampWeight -- clamp(v_start + slope * (e - t_start), floor, cap). Not a dispatched + // method; exposed directly. + // ------------------------------------------------------------------------- + + function _clampWeight(WeightRecord memory record, uint64 epoch) internal pure returns (int256) { + return rewardActor().clampWeight(record, epoch); + } + + function test_ClampWeight_AtTStart_ReturnsVStart() public pure { + WeightRecord memory r = _record(0.95e18, -100, 1000, 0.5e18, 0.95e18); + assertEq(_clampWeight(r, 1000), 0.95e18); + } + + 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(_clampWeight(r, 1500), 0.95e18 - 50_000); + } + + 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(_clampWeight(r, 1010), 0.5e18); + } + + 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(_clampWeight(r, 0), 0.95e18); + } + + function test_ClampWeight_ZeroSlope_IsConstant() public pure { + WeightRecord memory r = _constantRecord(0.3e18); + assertEq(_clampWeight(r, 0), 0.3e18); + assertEq(_clampWeight(r, 1_000_000), 0.3e18); + } + + function test_GetState_Empty_ReturnsNoStreams() public { + assertEq(_streams().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(); + + 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); + } + + function test_RegisterStream_NotSwa_Forbidden() public { + 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 { + uint32 exitCode = swaCaller.registerStream( + SERVICE_ID, + _constantRecord(0.1e18), + DistributionKind.IMPLICIT, + address(0), + uint64(block.number) + SWA_TIMELOCK - 1 + ); + 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 = 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); + assertEq(_streams().length, 0, "stream must not be live before its activation epoch"); + + _warpPastTimelockAndSettle(); + StreamView[] memory streams = _streams(); + assertEq(streams.length, 1); + assertEq(streams[0].id, SERVICE_ID); + } + + function test_RegisterStream_TombstonedId_IllegalArgument() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + 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.removeStream(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); + } + + // ------------------------------------------------------------------------- + // 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 = _setWeightRecords(randomCaller, SERVICE_ID, _constantRecord(0.1e18)); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_SetWeightRecords_NonexistentStream_NotFound() public { + 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.setWeightRecords(ids, records); + 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.setWeightRecords(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(); + WeightRecord memory bad = _record(0, 0, 0, 0.9e18, 0.1e18); // floor > cap + uint32 exitCode = _setWeightRecords(swaCaller, 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 = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.8e18)); + 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 = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.6e18)); + assertEq(firstExit, 0); + + uint32 secondExit = _setWeightRecords(swaCaller, 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(); + + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.7e18)); + assertEq(setExit, 0); + + StreamView[] memory before = _streams(); + assertEq(before[0].weightRecord.vStart, 0.1e18, "must not apply before the timelock elapses"); + + _warpPastTimelockAndSettle(); + StreamView[] memory afterSettle = _streams(); + assertEq(afterSettle[0].weightRecord.vStart, 0.7e18); + } + + // ------------------------------------------------------------------------- + // StepWeightRecords -- queued under its own (id, STEP_WEIGHT) slot, coexists with SetWeightRecords. + // ------------------------------------------------------------------------- + + function test_StepWeightRecords_NotSwa_Forbidden() public { + (uint32 exitCode,) = + randomCaller.call(STEP_WEIGHT_RECORDS, _setWeightParams(SERVICE_ID, _constantRecord(0.1e18))); + assertEq(exitCode, USR_FORBIDDEN); + } + + 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 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 = _setWeightRecords(swaCaller, 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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_SetShares_NonexistentStream_NotFound() public { + 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.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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL - 1)); + assertEq(under, USR_ILLEGAL_ARGUMENT); + uint32 over = writerCaller.setShares(SERVICE_ID, _shares(RECIPIENT_A, 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.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.setShares(SERVICE_ID, shares_); + assertEq(exitCode, 0); + + // 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, RECIPIENT_A); + assertEq(got[0].share, SHARE_TOTAL); + } + + function test_SetShares_FoldsAccruedIntoPayable_AndBurnsResidue() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); // weight 0.1e18 + 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.setShares(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)); + uint32 exitCode = randomCaller.removeStream(SERVICE_ID); + assertEq(exitCode, USR_FORBIDDEN); + } + + function test_RemoveStream_Nonexistent_NotFound() public { + 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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); + assertEq(setExit, 0); + + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); + assertEq(removeExit, 0); + assertEq(_streams().length, 1, "must still be live before the timelock elapses"); + + _warpPastTimelockAndSettle(); + assertEq(_streams().length, 0); + assertEq(rewardActor().getShares(SERVICE_ID).length, 0); + } + + function test_RemoveStream_NoOutstandingPayable_NoTombstoneCreated() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + uint32 removeExit = swaCaller.removeStream(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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); + assertEq(setSharesExit, 0); + + rewardActor().mockAwardBlockReward(1 ether); // 0.1 ether accrues, never claimed + + uint32 removeExit = swaCaller.removeStream(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)); + 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.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.setDistribution(SERVICE_ID, DistributionKind.EXPLICIT, address(0)); + assertEq(exitCode, USR_ILLEGAL_ARGUMENT); + } + + // 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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); + assertEq(initialSet, 0); + + 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.setShares(SERVICE_ID, _shares(RECIPIENT_B, SHARE_TOTAL)); + assertEq(oldWriterExit, 0); + 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.setShares(SERVICE_ID, _shares(address(0xF00D), SHARE_TOTAL)); + assertEq(oldWriterExitTooLate, USR_FORBIDDEN); + 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.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.setDistribution(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 = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.9e18)); + assertEq(setExit, 0); + 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.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); + assertEq(exitCode, 0); + } + + function test_CancelPending_DiscardsQueuedSetWeightRecords() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + uint32 setExit = _setWeightRecords(swaCaller, SERVICE_ID, _constantRecord(0.9e18)); + assertEq(setExit, 0); + + uint32 cancelExit = swaCaller.cancelPending(SERVICE_ID, PendingOp.SET_WEIGHT); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + 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.cancelPending(SERVICE_ID, PendingOp.REGISTER); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + assertEq(_streams().length, 0, "cancelled registration must never take effect"); + } + + function test_CancelPending_DiscardsQueuedRemoveStream() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + uint32 removeExit = swaCaller.removeStream(SERVICE_ID); + assertEq(removeExit, 0); + + uint32 cancelExit = swaCaller.cancelPending(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.cancelPending(SERVICE_ID, PendingOp.STEP_WEIGHT); + assertEq(cancelExit, 0); + + _warpPastTimelockAndSettle(); + assertEq(_streams()[0].weightRecord.vStart, 0.1e18); + } + + function test_CancelPending_OnlyCancelsMatchingOp() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + 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.cancelPending(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.setShares(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.setShares(SERVICE_ID, _shares(RECIPIENT_A, SHARE_TOTAL)); + assertEq(setSharesExit, 0); + + 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 _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)); + 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.setShares(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(); + + (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 { + (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); + } + + function test_AwardBlockReward_SelfIssues_ClaimPaysWithoutPreDeal() public { + _registerExplicit(SERVICE_ID, address(writerCaller)); + uint32 setExit = writerCaller.setShares(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 + // ------------------------------------------------------------------------- + + // 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); + (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); + } + + // 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 + // ------------------------------------------------------------------------- + + // 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); + 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/MockRewardTest.sol b/test/mocks/MockRewardTest.sol new file mode 100644 index 0000000..b0159d9 --- /dev/null +++ b/test/mocks/MockRewardTest.sol @@ -0,0 +1,26 @@ +// 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 its +/// stream-splitting methods. +contract MockRewardTest is MockFVMTest { + function setUp() public virtual override { + super.setUp(); + vm.etch(REWARD_ACTOR_ADDRESS, address(new FVMRewardActor(vm)).code); + FVMRewardActor(REWARD_ACTOR_ADDRESS).mockInit(); + vm.etch( + CALL_ACTOR_BY_ID, address(new FVMCallActorByIdWithReward(vm, FVMRewardActor(REWARD_ACTOR_ADDRESS))).code + ); + } + + function rewardActor() internal pure returns (FVMRewardActor) { + return FVMRewardActor(REWARD_ACTOR_ADDRESS); + } +}