Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ remappings = [
severity = ["high", "med", "low", "info", "gas", "code-size"]
exclude_lints = [
"incorrect-shift",
"mixed-case-function",
"multi-contract-file",
"unsafe-typecast",
"unwrapped-modifier-logic",
Expand Down
164 changes: 164 additions & 0 deletions src/StreamWeightActor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {IServiceRewardsActor} from "./interfaces/IServiceRewardsActor.sol";
import {Epoch} from "./lib/Epoch.sol";
import {FixedU18} from "./lib/FixedU18.sol";
import {GateParams, GateParamsLibrary} from "./lib/GateParams.sol";
import {FVMRewards} from "./lib/FVMRewards.sol";
import {PendingOp, Share, WeightRecord, WeightRecordUpdate} from "./lib/FVMRewardTypes.sol";
import {OwnersLibrary} from "./lib/Owners.sol";
import {UnanimousGovernance} from "./lib/UnanimousGovernance.sol";
import {IsASafe} from "./lib/IsASafe.sol";

uint64 constant SERVICE_ID = 2;

int256 constant STEP = 5e16; // 5%

/// @notice Owner-governed actor with sudo control over every f02 stream's weight schedule
/// (FIP-0118, solstice#3): registers, removes, reweights, and reassigns writers by stream id.
/// @dev Writes require unanimous owner approval, except `cancelPending`/`cancelPendingWeight`
/// (any single owner, immediate) and `quarterlyGateCheck` (fully permissionless).
contract StreamWeightActor is UnanimousGovernance {
using IsASafe for address;
using OwnersLibrary for address;

IServiceRewardsActor immutable SRA;
Epoch immutable QUARTER;
Epoch immutable HOLD;

/// @notice Deploys the actor with its two initial owners, bound to a Service Rewards Actor.
/// @param owner1 First owner; must be a Safe.
/// @param owner2 Second owner; must be a Safe.
/// @param sra Service Rewards Actor supplying QUARTER/HOLD and gating `quarterlyGateCheck`.
constructor(address owner1, address owner2, IServiceRewardsActor sra) {
owner1.isProbablyASafe();
owner2.isProbablyASafe();

owner1.addOwner();
owner2.addOwner();

SRA = sra;
QUARTER = sra.EPOCHS_PER_QUARTER();
HOLD = sra.SRA_CANCEL_HOLD();

GateParamsLibrary.init();
}

/// @notice Queues a new implicit stream.
/// @dev f02 resolves the recipient from protocol state; no writer or share map is stored.
/// @param id Stream id to register.
/// @param record Initial weight schedule.
/// @param activationEpoch Epoch the schedule begins applying.
function registerStream(uint64 id, WeightRecord calldata record, uint64 activationEpoch)
Comment thread
wjmelements marked this conversation as resolved.
external
unanimousNoHold(keccak256(msg.data))
{
// hold enforced in f02
FVMRewards.registerStream(id, record, activationEpoch);
}

/// @notice Queues a new explicit stream with its designated writer and initial share map.
/// @param id Stream id to register.
/// @param record Initial weight schedule.
/// @param writer Address permitted to update the share map.
/// @param shares Initial wallet-to-share map; must be valid at registration.
/// @param activationEpoch Epoch the schedule begins applying.
function registerStream(
uint64 id,
WeightRecord calldata record,
address writer,
Share[] calldata shares,
uint64 activationEpoch
) external unanimousNoHold(keccak256(msg.data)) {
// hold enforced in f02
FVMRewards.registerStream(id, record, writer, shares, activationEpoch);
}

/// @notice Queues removal of a stream.
/// @param id Stream id to remove.
function removeStream(uint64 id) external unanimousNoHold(keccak256(msg.data)) {
// hold enforced in f02
FVMRewards.removeStream(id);
}

/// @notice Queues a discretionary weight-schedule write for one or more streams.
/// @param updates Id/record pairs to write.
function setWeightRecords(WeightRecordUpdate[] calldata updates) external unanimousNoHold(keccak256(msg.data)) {
// hold enforced in f02
FVMRewards.setWeightRecords(updates);
}

/// @notice Queues a writer change for an explicit stream.
/// @param id Stream id whose writer changes.
/// @param writer New designated writer.
function setDistribution(uint64 id, address writer) external unanimousNoHold(keccak256(msg.data)) {
// hold enforced in f02
FVMRewards.setDistribution(id, writer);
}

/// @notice Cancels a queued per-stream operation (register, remove, or setDistribution).
/// @dev Any current owner, immediate, bypassing unanimity.
/// @param id Stream id the pending operation targets.
/// @param op Kind of pending operation to cancel.
function cancelPending(uint64 id, PendingOp op) external {
// any owner can immediately cancel any pending operation
require(msg.sender.isOwner());
FVMRewards.cancelPending(id, op);
}

/// @notice Cancels a queued discretionary weight-schedule write (SetWeightRecords).
/// @dev Any current owner, immediate. Cannot cancel a gate-originated StepWeightRecords write.
/// @param op Weight operation to cancel.
function cancelPendingWeight(PendingOp op) external {
// any owner can immediately cancel any pending operation
require(msg.sender.isOwner());
FVMRewards.cancelPendingWeight(op);
}

/// @notice Replaces one of the two owners.
/// @param prevOwner Owner being removed.
/// @param newOwner Owner being added; must be a Safe.
function replaceOwner(address prevOwner, address newOwner) external unanimousNoHold(keccak256(msg.data)) {
newOwner.isProbablyASafe();
prevOwner.removeOwner();
newOwner.addOwner();
}

/// @notice All 8 gate steps have already been taken.
error StepsComplete();

/// @notice Advances the quarterly gate by one quarter, stepping SERVICE_ID's weight schedule
/// if the elapsed quarter's aggregated FPV cleared the next volume threshold.
/// @dev Permissionless; reverts via the SRA if the quarter's FPV is not yet bound.
function quarterlyGateCheck() external {
GateParamsLibrary.GateParamsInfo storage gateParamsInfo = GateParamsLibrary.getGateParamsSlot();
GateParams memory loaded = gateParamsInfo.params;
require(loaded.steps < 8, StepsComplete());

uint64 quarter = ++gateParamsInfo.lastCheckedQuarter;
// NOTE this will enforce afterBinding()
Comment thread
wjmelements marked this conversation as resolved.
FixedU18 fpv = SRA.aggregatedFPV(quarter);

if (fpv >= loaded.nextThreshold()) {
int256 next = (int256(uint256(loaded.steps)) + 3) * STEP;

WeightRecordUpdate[] memory updates = new WeightRecordUpdate[](1);
updates[0].id = SERVICE_ID;
updates[0].record.floor = next;
updates[0].record.tStart = SRA.qEnd(quarter);
updates[0].record.vStart = next;
updates[0].record.cap = next;
updates[0].record.slope = 0;

gateParamsInfo.params.steps++;
FVMRewards.stepWeightRecords(updates);
}
}

/// @notice Overwrites the quarterly gate's parameters; has a HOLD-epoch timelock after unanimity.
/// @param params New volume target and step state.
function setGateParams(GateParams calldata params) external unanimous(keccak256(msg.data), HOLD) {
GateParamsLibrary.getGateParamsSlot().params = params;
}
}
12 changes: 12 additions & 0 deletions src/interfaces/IServiceRewardsActor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

import {Epoch} from "../lib/Epoch.sol";
import {FixedU18} from "../lib/FixedU18.sol";

interface IServiceRewardsActor {
function aggregatedFPV(uint64 quarter) external view returns (FixedU18 filecoinPayVolume);
function qEnd(uint64 quarter) external view returns (Epoch quarterEnd);
function EPOCHS_PER_QUARTER() external view returns (Epoch oneQuarter);
function SRA_CANCEL_HOLD() external view returns (Epoch hold);
}
2 changes: 1 addition & 1 deletion src/lib/Epoch.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

type Epoch is uint96;
type Epoch is uint64;

using {
add as +,
Expand Down
4 changes: 3 additions & 1 deletion src/lib/FVMRewardTypes.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

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

/// @notice A stream's Distribution kind (FIP-0118 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.
Expand All @@ -14,7 +16,7 @@ enum DistributionKind {
struct WeightRecord {
int256 vStart;
int256 slope;
uint64 tStart;
Epoch tStart;
int256 floor;
int256 cap;
}
Expand Down
3 changes: 2 additions & 1 deletion src/lib/FVMRewards.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {NO_FLAGS} from "fvm-solidity/FVMFlags.sol";
import {CBOR_CODEC} from "fvm-solidity/FVMCodec.sol";
import {EXIT_SUCCESS} from "fvm-solidity/FVMErrors.sol";

import {Epoch} from "./Epoch.sol";
import {
REGISTER_STREAM,
REMOVE_STREAM,
Expand Down Expand Up @@ -185,7 +186,7 @@ library FVMRewards {
p = _writeArrayHeader(p, 5);
p = _writeUint(p, _u64(r.vStart));
p = _writeInt(p, r.slope);
p = _writeInt(p, int256(uint256(r.tStart)));
p = _writeInt(p, int256(uint256(Epoch.unwrap(r.tStart))));
p = _writeUint(p, _u64(r.floor));
p = _writeUint(p, _u64(r.cap));
return p;
Expand Down
106 changes: 106 additions & 0 deletions src/lib/FixedU18.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

type FixedU18 is uint256;

using {
unsafeAdd as +,
unsafeSub as -,
unsafeMulDown as *,
divDown as /,
equals as ==,
greaterThan as >,
lessThan as <,
greaterThanOrEqualTo as >=,
lessThanOrEqualTo as <=
} for FixedU18 global;

using FixedU18Library for FixedU18 global;

uint256 constant ONE_WAD = 1 ether;
FixedU18 constant ONE = FixedU18.wrap(ONE_WAD);

// type(uint256).max / ONE_WAD
uint256 constant MAX_DIVIDEND_WAD = 115792089237316195423570985008687907853269984665640564039457;
FixedU18 constant MAX_DIVIDEND = FixedU18.wrap(MAX_DIVIDEND_WAD);

error DividendTooLarge(FixedU18 dividend);
bytes4 constant DIVIDEND_TOO_LARGE_SELECTOR = 0x7b5479ff;

// @dev returns zero if divisor is zero
function divDown(FixedU18 dividend, FixedU18 divisor) pure returns (FixedU18 quotient) {
assembly ("memory-safe") {
if gt(dividend, MAX_DIVIDEND_WAD) {
mstore(0, DIVIDEND_TOO_LARGE_SELECTOR)
mstore(32, dividend)
revert(28, 36)
}
quotient := div(mul(dividend, ONE_WAD), divisor)
}
}

// @dev overflows if sum would be greater than 2**256/10**18 (approximatly 10**59)
Comment thread
wjmelements marked this conversation as resolved.
function unsafeAdd(FixedU18 addend1, FixedU18 addend2) pure returns (FixedU18 sum) {
assembly ("memory-safe") {
sum := add(addend1, addend2)
}
}

// @dev underflows if subtrahend is greater than minuend
function unsafeSub(FixedU18 minuend, FixedU18 subtrahend) pure returns (FixedU18 difference) {
assembly ("memory-safe") {
difference := sub(minuend, subtrahend)
}
}

// @dev overflows if product would be greater than 2**256/10**18 (approximatly 10**59)
function unsafeMulDown(FixedU18 factor1, FixedU18 factor2) pure returns (FixedU18 product) {
assembly ("memory-safe") {
product := div(mul(factor1, factor2), ONE_WAD)
}
}

function equals(FixedU18 a, FixedU18 b) pure returns (bool) {
return FixedU18.unwrap(a) == FixedU18.unwrap(b);
}

function greaterThan(FixedU18 a, FixedU18 b) pure returns (bool) {
return FixedU18.unwrap(a) > FixedU18.unwrap(b);
}

function lessThan(FixedU18 a, FixedU18 b) pure returns (bool) {
return FixedU18.unwrap(a) < FixedU18.unwrap(b);
}

function greaterThanOrEqualTo(FixedU18 a, FixedU18 b) pure returns (bool) {
return FixedU18.unwrap(a) >= FixedU18.unwrap(b);
}

function lessThanOrEqualTo(FixedU18 a, FixedU18 b) pure returns (bool) {
return FixedU18.unwrap(a) <= FixedU18.unwrap(b);
}

library FixedU18Library {
// @dev overflows if product would be greater than 2**256/10**18 (approximatly 10**59)
function mul(FixedU18 factor1, uint256 factor2) internal pure returns (FixedU18 product) {
assembly ("memory-safe") {
product := mul(factor1, factor2)
}
}

function exp(FixedU18 base, uint64 exponent) internal pure returns (FixedU18 power) {
assembly ("memory-safe") {
power := ONE_WAD
if exponent {
for {} gt(exponent, 1) {} {
if and(1, exponent) {
power := div(mul(base, power), ONE_WAD)
}
base := div(mul(base, base), ONE_WAD)
exponent := shr(1, exponent)
}
power := div(mul(base, power), ONE_WAD)
}
}
}
}
47 changes: 47 additions & 0 deletions src/lib/GateParams.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: Apache-2.0 OR MIT
pragma solidity ^0.8.36;

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

FixedU18 constant VOL_TARGET_ENTRY = FixedU18.wrap(3500 ether);
FixedU18 constant VOL_TARGET_RATIO = FixedU18.wrap(2.7 ether);

struct VolumeTarget {
FixedU18 base;
FixedU18 stepRatio;
}

struct GateParams {
VolumeTarget target;
uint64 steps;
}

using GateParamsLibrary for GateParams global;

library GateParamsLibrary {
/// @custom:storage-location erc7201:Solstice.GateParams
struct GateParamsInfo {
uint64 lastCheckedQuarter;
GateParams params;
}

// keccak256(abi.encode(uint256(keccak256("Solstice.GateParams")) - 1)) & ~bytes32(uint256(0xff));
bytes32 private constant GATE_PARAMS_SLOT = 0xf9abab00248d945495524c8caf6be2b837274c1becd1964fb3775f62fd6e4600;

function getGateParamsSlot() internal pure returns (GateParamsInfo storage slot) {
assembly ("memory-safe") {
slot.slot := GATE_PARAMS_SLOT
}
}

function nextThreshold(GateParams memory params) internal pure returns (FixedU18 fpvThreshold) {
return params.target.base * params.target.stepRatio.exp(params.steps);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a floor here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll just remove the floor from the spec.

}

function init() internal {
GateParamsInfo storage slot = GateParamsLibrary.getGateParamsSlot();
slot.lastCheckedQuarter = 1;
slot.params.target.base = VOL_TARGET_ENTRY;
slot.params.target.stepRatio = VOL_TARGET_RATIO;
}
}
4 changes: 2 additions & 2 deletions src/lib/IsASafe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ library IsASafe {

function isProbablyASafe(address account) internal view {
uint256 codesize = account.code.length;
// observed Safe proxy codesize range is 110 (v1.0.0) to 171 (v1.3.0)
require(codesize > 80 && codesize < 240, NotSafeProxy(account));
// observed Safe proxy codesize range is 60 (stripped v1.5.0) to 171 (v1.3.0)
require(codesize > 56 && codesize < 240, NotSafeProxy(account));
address implementation = IProxy(account).masterCopy();
// observed Safe masterCopy size is 20869 (v1.5.0) to 24421 (v1.4.1)
require(implementation.code.length > 8000, UnusualSafeMasterCopy(account, implementation));
Expand Down
Loading