diff --git a/README.md b/README.md index 6619800..e22f3ca 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ Pooled, autonomous, market-cap-weighted index vault on Ethereum mainnet. Users e ## Status -Phases 1 and 2 of the six-phase build plan (SPEC Section 13) are implemented and tested. +Phases 1 through 3 of the six-phase build plan (SPEC Section 13) are implemented and tested. | Phase | Scope | Status | |-------|-------|--------| | 1 | Core vault: ERC-7540 async superset of ERC-4626, NAV, buffer, PendingSilo, settle | Done | | 2 | Methodology engine: market-cap weighting, iterative capping, floor, buffer rule | Done | -| 3 | Layered supply oracle (on-chain derivation, median with freeze, containment) | Next | -| 4 | Rebalancer and Composable CoW order handler | Planned | +| 3 | Layered supply oracle (on-chain derivation, median with freeze, containment) | Done | +| 4 | Rebalancer and Composable CoW order handler | Next | | 5 | Fees, timelocks, guardian pause | Planned | | 6 | Mainnet-fork end-to-end at index-10 | Planned | @@ -21,7 +21,7 @@ Phases 1 and 2 of the six-phase build plan (SPEC Section 13) are implemented and - `PendingSilo` isolates in-flight value (unsettled deposit USDC, escrowed shares, claimable balances) so the vault's NAV is structurally clean rather than corrected by bookkeeping. - `ComponentRegistry` holds constituents with per-feed Chainlink heartbeats. Every price read is health-checked and stale data makes price-sensitive operations revert. - `MarketCapMethodology` implements `IMethodology`: float-adjusted market cap weighting with a hard per-asset cap redistributed iteratively to convergence, plus a minimum-weight floor that prunes dust positions. The capping math lives in the pure `WeightMath` library with exact invariants: weights sum to exactly 1e18, no weight exceeds the cap, and infeasible configurations revert instead of degrading. -- `ISupplyOracle` is the seam for the Phase 3 layered supply oracle, which is the protocol's central risk and is specified in SPEC Section 8. +- The supply oracle is the protocol's central risk (SPEC Section 8) and is built in three layers behind the `ISupplyOracle` seam. `ExcludedAddressRegistry` (Layer 1) derives circulating supply on-chain as `totalSupply - Σ balanceOf(excluded)`, converting "trust a number" into "trust a timelocked list of addresses," with `totalSupply` as a free trustless upper bound. `SupplyOracle` (Layers 2 and 3) secures the residual free-float factor through a multi-source reporter median that freezes the constituent at last-good when sources diverge, then contains it with a per-commit rate-limit, a hard staleness ceiling, and a guardian pause. Because the factor is capped at 1e18, free-float can never exceed the on-chain floor by construction. ## Development @@ -32,4 +32,4 @@ forge build forge test ``` -The test suite covers the full async request, settle, and claim lifecycle, buffer-band gating of the sync lanes, oracle staleness fail-closed behavior, settlement liveness and flash-loan guards, and property fuzzing of the capping algorithm. +The test suite (75 tests) covers the full async request, settle, and claim lifecycle, buffer-band gating of the sync lanes, oracle staleness fail-closed behavior, settlement liveness and flash-loan guards, property fuzzing of the capping algorithm, and adversarial supply-oracle scenarios (divergence freeze, rate-limit clamp convergence, timelocked exclusions, guardian pause), including an end-to-end test driving the methodology through the real layered supply oracle. diff --git a/src/interfaces/ISupplyOracle.sol b/src/interfaces/ISupplyOracle.sol index 753a3fa..f59dca5 100644 --- a/src/interfaces/ISupplyOracle.sol +++ b/src/interfaces/ISupplyOracle.sol @@ -5,12 +5,20 @@ pragma solidity 0.8.28; * @title ISupplyOracle * @notice Source of float-adjusted circulating supply per constituent. The * methodology engine consumes an already-float-adjusted figure and does not - * itself decide float (spec Section 5.2). The Phase 3 implementation layers - * on-chain derivation, a multi-source median with divergence freeze, and - * containment guards behind this interface, so the methodology never needs - * to know how the number was secured. + * itself decide float (spec Section 5.2). The implementation layers on-chain + * derivation, a multi-source median with divergence freeze, and containment + * guards behind this interface, so the methodology never needs to know how + * the number was secured. * @dev Supply is reported in whole-token units, never native token decimals. - * Implementations MUST revert rather than return a stale or disputed value. + * + * Freeze versus revert: because supply is the slow-moving input (price, the + * fast input, is Chainlink's job), a residual source that goes quiet does not + * halt the index. Soft staleness and source divergence FREEZE the constituent + * at its last-good value rather than reverting; the slow nature of supply + * makes a pinned figure safe for a bounded window. The view MUST revert only + * on hard failures: paused, never initialized, or a last-good older than the + * hard ceiling. That revert propagates into the methodology, which fails + * closed for the whole rebalance, exactly as a stale price does. */ interface ISupplyOracle { /// @notice Float-adjusted circulating supply of `token`, in whole tokens. diff --git a/src/oracle/ExcludedAddressRegistry.sol b/src/oracle/ExcludedAddressRegistry.sol new file mode 100644 index 0000000..be5ada2 --- /dev/null +++ b/src/oracle/ExcludedAddressRegistry.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// ============================================================================ +// Errors +// ============================================================================ + +/// @notice Thrown when a constructor or setter receives the zero address. +error ExcludedRegistry_ZeroAddress(); + +/// @notice Thrown when proposing a change that is already pending. +error ExcludedRegistry_ChangeAlreadyPending(bytes32 id); + +/// @notice Thrown when executing or cancelling a change that was never proposed. +error ExcludedRegistry_NoPendingChange(bytes32 id); + +/// @notice Thrown when executing a change before its timelock has elapsed. +error ExcludedRegistry_TimelockNotElapsed(bytes32 id, uint256 eta); + +/// @notice Thrown when a proposed change is redundant (excluding an address +/// already excluded, or including one not currently excluded). +error ExcludedRegistry_NoOp(address token, address account, bool exclude); + +/// @notice Thrown when setting a delay outside the allowed band. +error ExcludedRegistry_InvalidDelay(uint256 delay); + +/** + * @title ExcludedAddressRegistry + * @notice Layer 1 of the supply oracle (spec Section 8.1): minimize the + * off-chain surface by deriving circulating supply on-chain wherever possible. + * + * Circulating supply is computed directly as + * + * circulating = totalSupply - Σ balanceOf(excludedAddress) + * + * which converts "trust a number" into "trust a list of addresses." Every + * excluded address is a falsifiable public claim (this is a vesting contract, + * this is the team multisig, this is a burn sink) that any observer can audit, + * and totalSupply itself is a free, trustless upper bound: no derived figure + * can exceed it. + * + * Because the excluded set is the entire trust surface of Layer 1, every + * addition and removal is timelocked. A change is visible on-chain for the + * full delay before it can take effect, so a malicious or mistaken edit can + * be seen and contested before it moves any weight. + */ +contract ExcludedAddressRegistry is Ownable2Step { + struct PendingChange { + uint64 eta; + bool exclude; // true: add to excluded set; false: remove + bool exists; + } + + uint256 public constant MIN_DELAY = 1 hours; + uint256 public constant MAX_DELAY = 30 days; + + /// @notice Timelock applied to every excluded-set change. + uint256 public delay; + + /// @dev Per-token excluded address list, in insertion order. + mapping(address token => address[]) private _excludedList; + + /// @notice Whether `account` is currently excluded for `token`. + mapping(address token => mapping(address account => bool)) public isExcluded; + + /// @dev Pending changes keyed by changeId(token, account, exclude). + mapping(bytes32 id => PendingChange) public pendingChanges; + + event DelaySet(uint256 delay); + event ChangeProposed(bytes32 indexed id, address indexed token, address indexed account, bool exclude, uint256 eta); + event ChangeCancelled(bytes32 indexed id, address indexed token, address indexed account, bool exclude); + event ChangeExecuted(bytes32 indexed id, address indexed token, address indexed account, bool exclude); + + constructor(address initialOwner, uint256 initialDelay) Ownable(initialOwner) { + if (initialDelay < MIN_DELAY || initialDelay > MAX_DELAY) revert ExcludedRegistry_InvalidDelay(initialDelay); + delay = initialDelay; + emit DelaySet(initialDelay); + } + + // ======================================================================== + // Timelocked change lifecycle + // ======================================================================== + + /// @notice Deterministic id for a (token, account, direction) change. + function changeId(address token, address account, bool exclude) public pure returns (bytes32) { + return keccak256(abi.encode(token, account, exclude)); + } + + /// @notice Proposes adding or removing `account` from `token`'s excluded + /// set. The change cannot take effect until `delay` has elapsed. + function proposeChange(address token, address account, bool exclude) external onlyOwner returns (bytes32 id) { + if (token == address(0) || account == address(0)) revert ExcludedRegistry_ZeroAddress(); + // Reject redundant changes so the pending queue only holds real edits. + if (isExcluded[token][account] == exclude) revert ExcludedRegistry_NoOp(token, account, exclude); + + id = changeId(token, account, exclude); + if (pendingChanges[id].exists) revert ExcludedRegistry_ChangeAlreadyPending(id); + + uint64 eta = uint64(block.timestamp + delay); + pendingChanges[id] = PendingChange({ eta: eta, exclude: exclude, exists: true }); + emit ChangeProposed(id, token, account, exclude, eta); + } + + /// @notice Cancels a pending change before it executes. The owner today, + /// the guardian once Phase 5 wires fast pause powers in. + function cancelChange(address token, address account, bool exclude) external onlyOwner { + bytes32 id = changeId(token, account, exclude); + if (!pendingChanges[id].exists) revert ExcludedRegistry_NoPendingChange(id); + delete pendingChanges[id]; + emit ChangeCancelled(id, token, account, exclude); + } + + /// @notice Executes a pending change once its timelock has elapsed. + /// @dev Permissionless: the timelock, not the caller, is the gate. Anyone + /// can finalize a change the owner has already publicly committed to. + function executeChange(address token, address account, bool exclude) external { + bytes32 id = changeId(token, account, exclude); + PendingChange memory change = pendingChanges[id]; + if (!change.exists) revert ExcludedRegistry_NoPendingChange(id); + if (block.timestamp < change.eta) revert ExcludedRegistry_TimelockNotElapsed(id, change.eta); + + delete pendingChanges[id]; + + if (exclude) { + // Guard against a redundant add that slipped in between propose and + // execute (e.g., the same account added via two ids). + if (!isExcluded[token][account]) { + isExcluded[token][account] = true; + _excludedList[token].push(account); + } + } else { + if (isExcluded[token][account]) { + isExcluded[token][account] = false; + _removeFromList(token, account); + } + } + + emit ChangeExecuted(id, token, account, exclude); + } + + /// @notice Sets the timelock delay for future changes. + function setDelay(uint256 newDelay) external onlyOwner { + if (newDelay < MIN_DELAY || newDelay > MAX_DELAY) revert ExcludedRegistry_InvalidDelay(newDelay); + delay = newDelay; + emit DelaySet(newDelay); + } + + // ======================================================================== + // Views and derivation + // ======================================================================== + + /// @notice The current excluded-address set for `token`. + function getExcluded(address token) external view returns (address[] memory) { + return _excludedList[token]; + } + + /// @notice Number of excluded addresses for `token`. + function excludedCount(address token) external view returns (uint256) { + return _excludedList[token].length; + } + + /** + * @notice On-chain-derived circulating supply of `token` in WHOLE tokens: + * totalSupply minus the balance of every excluded address, scaled down by + * the token's decimals. + * @dev The subtraction reverts on underflow, which fails closed: the + * excluded set is by construction a subset of holders, so a sum exceeding + * totalSupply signals a corrupted registry rather than a real state. + */ + function onChainCirculating(address token) external view returns (uint256) { + uint256 total = IERC20(token).totalSupply(); + + address[] storage list = _excludedList[token]; + uint256 excludedSum = 0; + for (uint256 i = 0; i < list.length; i++) { + excludedSum += IERC20(token).balanceOf(list[i]); + } + + uint256 circulatingRaw = total - excludedSum; // underflow reverts: fail closed + return circulatingRaw / (10 ** IERC20Metadata(token).decimals()); + } + + // ======================================================================== + // Internal + // ======================================================================== + + /// @dev Swap-and-pop removal of `account` from `token`'s excluded list. + function _removeFromList(address token, address account) private { + address[] storage list = _excludedList[token]; + uint256 len = list.length; + for (uint256 i = 0; i < len; i++) { + if (list[i] == account) { + list[i] = list[len - 1]; + list.pop(); + return; + } + } + } +} diff --git a/src/oracle/SupplyOracle.sol b/src/oracle/SupplyOracle.sol new file mode 100644 index 0000000..b65fcab --- /dev/null +++ b/src/oracle/SupplyOracle.sol @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; +import { ExcludedAddressRegistry } from "src/oracle/ExcludedAddressRegistry.sol"; + +// ============================================================================ +// Errors +// ============================================================================ + +error SupplyOracle_ZeroAddress(); +error SupplyOracle_NotReporter(address caller); +error SupplyOracle_NotGuardian(address caller); +error SupplyOracle_Paused(); + +/// @notice Thrown when the free-float factor read or reported exceeds 1e18, +/// which would claim more free float than circulating supply. +error SupplyOracle_FactorAboveOne(uint256 factorWad); + +/// @notice Thrown when a token has no committed factor yet (never initialized). +error SupplyOracle_NotInitialized(address token); + +/// @notice Thrown by the read path when the last commit is older than the hard +/// ceiling, distinct from the soft freeze that serves last-good. +error SupplyOracle_CommitTooOld(address token, uint256 committedAt, uint256 maxAge); + +/// @notice Thrown when a commit cannot gather enough fresh reporter values. +error SupplyOracle_NotEnoughFreshReports(address token, uint256 fresh, uint256 required); + +/// @notice Thrown when fresh reporter values disagree beyond tolerance: the +/// divergence freeze. The commit reverts, so last-good persists untouched. +error SupplyOracle_SourcesDiverged(address token, uint256 spreadBps, uint256 toleranceBps); + +error SupplyOracle_AlreadyReporter(address reporter); +error SupplyOracle_UnknownReporter(address reporter); +error SupplyOracle_TooManyReporters(); +error SupplyOracle_InvalidParams(); + +/** + * @title SupplyOracle + * @notice Layers 2 and 3 of the supply-oracle design (spec Section 8), built + * on the Layer 1 on-chain derivation in ExcludedAddressRegistry. + * + * Free-float supply is expressed as + * + * freeFloat = onChainCirculating * freeFloatFactor / 1e18 + * + * where onChainCirculating is the trustless Layer 1 figure and freeFloatFactor + * in (0, 1e18] is the secured residual: the fraction of on-chain-circulating + * supply that is genuinely free-floating once off-chain lock status (exchange + * cold storage, OTC locks, off-chain vesting) is accounted for. Expressing the + * residual as a bounded factor rather than an absolute number is what makes + * the on-chain floor a hard cap on the output: factor <= 1e18 means free-float + * can never exceed circulating, by construction. + * + * Layer 2, secure the residual. Independent reporters push factor values. A + * commit gathers the fresh ones, takes their median, and requires at least + * `minReporters` of them to agree within `divergenceToleranceBps`. If they + * disagree, the commit reverts and the constituent freezes at its last-good + * factor rather than acting on disputed data. This interface is shaped so an + * optimistic oracle can later replace the reporter-median residual per + * constituent without the methodology engine noticing (spec Section 8.2). + * + * Layer 3, contain. The committed factor moves toward the median by at most + * `maxFactorDeltaBps` per commit, so a correct-but-large change is approached + * gradually and a malicious spike cannot move the index more than one step + * before a human reacts. A hard `maxCommitAge` ceiling fails the read closed + * if every reporter has gone silent for too long, and the guardian can pause + * all reads outright. + */ +contract SupplyOracle is ISupplyOracle, Ownable2Step { + using Math for uint256; + + uint256 private constant WAD = 1e18; + uint256 private constant BPS = 10_000; + uint256 public constant MAX_REPORTERS = 16; + + struct Report { + uint256 factorWad; + uint64 timestamp; + } + + struct Committed { + uint256 factorWad; + uint64 timestamp; + bool initialized; + } + + /// @notice Layer 1 on-chain circulating-supply source. + ExcludedAddressRegistry public immutable EXCLUDED; + + /// @notice Guardian with pause-only powers (spec Section 10). + address public guardian; + + /// @notice When true, every read fails closed. + bool public paused; + + /// @dev Authorized reporter set. + address[] private _reporterList; + mapping(address reporter => bool) public isReporter; + + /// @dev Latest pushed value per (token, reporter). + mapping(address token => mapping(address reporter => Report)) public reports; + + /// @dev Committed (last-good) factor per token. + mapping(address token => Committed) public committed; + + // --- Layer 2 / Layer 3 parameters --- + + /// @notice Minimum number of agreeing fresh reports to commit (the k in k-of-n). + uint256 public minReporters = 2; + + /// @notice Maximum spread between agreeing reports, in bps of the median. + uint256 public divergenceToleranceBps = 200; + + /// @notice A report older than this is not fresh and is ignored on commit. + uint256 public reportStaleAfter = 1 days; + + /// @notice Hard ceiling: a committed factor older than this fails reads closed. + uint256 public maxCommitAge = 30 days; + + /// @notice Maximum per-commit move of the factor, in bps of the prior factor. + uint256 public maxFactorDeltaBps = 1000; + + event GuardianSet(address indexed guardian); + event PausedSet(bool paused); + event ReporterAdded(address indexed reporter); + event ReporterRemoved(address indexed reporter); + event Reported(address indexed token, address indexed reporter, uint256 factorWad); + event Committed_(address indexed token, uint256 factorWad, uint256 median, uint256 freshCount); + event ParamsSet( + uint256 minReporters, + uint256 divergenceToleranceBps, + uint256 reportStaleAfter, + uint256 maxCommitAge, + uint256 maxFactorDeltaBps + ); + + modifier onlyReporter() { + if (!isReporter[msg.sender]) revert SupplyOracle_NotReporter(msg.sender); + _; + } + + constructor(ExcludedAddressRegistry excluded, address guardian_, address initialOwner) Ownable(initialOwner) { + if (address(excluded) == address(0) || guardian_ == address(0)) revert SupplyOracle_ZeroAddress(); + EXCLUDED = excluded; + guardian = guardian_; + emit GuardianSet(guardian_); + } + + // ======================================================================== + // Read path (ISupplyOracle) + // ======================================================================== + + /// @inheritdoc ISupplyOracle + /// @dev Fails closed on pause, missing initialization, or a last-good + /// factor past the hard ceiling. Soft staleness (a quiet reporter set + /// inside maxCommitAge) keeps serving last-good: the freeze, not a revert. + function getFreeFloatSupply(address token) external view returns (uint256) { + if (paused) revert SupplyOracle_Paused(); + + Committed memory c = committed[token]; + if (!c.initialized) revert SupplyOracle_NotInitialized(token); + if (block.timestamp > c.timestamp + maxCommitAge) { + revert SupplyOracle_CommitTooOld(token, c.timestamp, maxCommitAge); + } + + uint256 circulating = EXCLUDED.onChainCirculating(token); + // factor <= WAD (enforced on commit), so free-float <= circulating. + return circulating.mulDiv(c.factorWad, WAD, Math.Rounding.Floor); + } + + /// @notice The committed free-float factor and its age, for off-chain + /// monitoring of which constituents are frozen. + function freeFloatFactor(address token) + external + view + returns (uint256 factorWad, uint256 committedAt, bool frozen) + { + Committed memory c = committed[token]; + factorWad = c.factorWad; + committedAt = c.timestamp; + // Frozen here means no fresh quorum could update it recently, surfaced + // for dashboards; the read still serves last-good until maxCommitAge. + frozen = c.initialized && block.timestamp > c.timestamp + reportStaleAfter; + } + + // ======================================================================== + // Layer 2: report and commit + // ======================================================================== + + /// @notice A reporter pushes its observed free-float factor for `token`. + function report(address token, uint256 factorWad) external onlyReporter { + if (token == address(0)) revert SupplyOracle_ZeroAddress(); + if (factorWad == 0 || factorWad > WAD) revert SupplyOracle_FactorAboveOne(factorWad); + reports[token][msg.sender] = Report({ factorWad: factorWad, timestamp: uint64(block.timestamp) }); + emit Reported(token, msg.sender, factorWad); + } + + /** + * @notice Consolidates fresh reporter values for `token` into a new + * committed factor. Permissionless: anyone (in practice a keeper) can call + * it, because every gate is on the data, not the caller. + * @dev Reverts, leaving last-good untouched, when there are too few fresh + * reports or when they diverge beyond tolerance. On success the committed + * factor moves toward the median by at most maxFactorDeltaBps. + */ + function commit(address token) external { + if (paused) revert SupplyOracle_Paused(); + + // Gather fresh reporter values. + uint256 n = _reporterList.length; + uint256[] memory fresh = new uint256[](n); + uint256 count = 0; + uint256 cutoff = block.timestamp - Math.min(block.timestamp, reportStaleAfter); + for (uint256 i = 0; i < n; i++) { + Report memory r = reports[token][_reporterList[i]]; + if (r.timestamp != 0 && r.timestamp >= cutoff) { + fresh[count++] = r.factorWad; + } + } + if (count < minReporters) revert SupplyOracle_NotEnoughFreshReports(token, count, minReporters); + + // Trim to the populated prefix and sort ascending for the median. + uint256[] memory values = new uint256[](count); + for (uint256 i = 0; i < count; i++) { + values[i] = fresh[i]; + } + _sort(values); + uint256 median = _median(values); + + // Divergence freeze: require at least minReporters within tolerance of + // the median. Otherwise revert so the constituent stays frozen. + uint256 band = median.mulDiv(divergenceToleranceBps, BPS, Math.Rounding.Ceil); + uint256 agree = 0; + for (uint256 i = 0; i < count; i++) { + uint256 diff = values[i] > median ? values[i] - median : median - values[i]; + if (diff <= band) agree++; + } + if (agree < minReporters) { + // Report the full spread for the revert reason. + uint256 spread = values[count - 1] - values[0]; + uint256 spreadBps = median == 0 ? 0 : spread.mulDiv(BPS, median, Math.Rounding.Ceil); + revert SupplyOracle_SourcesDiverged(token, spreadBps, divergenceToleranceBps); + } + + // Layer 3 rate-limit: clamp the move toward the median so a large jump + // is approached over several commits rather than landing at once. + Committed memory prev = committed[token]; + uint256 next = median; + if (prev.initialized) { + uint256 maxStep = prev.factorWad.mulDiv(maxFactorDeltaBps, BPS, Math.Rounding.Floor); + if (median > prev.factorWad + maxStep) { + next = prev.factorWad + maxStep; + } else if (median + maxStep < prev.factorWad) { + next = prev.factorWad - maxStep; + } + } + if (next > WAD) next = WAD; // structural cap; median is already <= WAD + + committed[token] = Committed({ factorWad: next, timestamp: uint64(block.timestamp), initialized: true }); + emit Committed_(token, next, median, count); + } + + // ======================================================================== + // Admin and guardian + // ======================================================================== + + function addReporter(address reporter) external onlyOwner { + if (reporter == address(0)) revert SupplyOracle_ZeroAddress(); + if (isReporter[reporter]) revert SupplyOracle_AlreadyReporter(reporter); + if (_reporterList.length >= MAX_REPORTERS) revert SupplyOracle_TooManyReporters(); + isReporter[reporter] = true; + _reporterList.push(reporter); + emit ReporterAdded(reporter); + } + + function removeReporter(address reporter) external onlyOwner { + if (!isReporter[reporter]) revert SupplyOracle_UnknownReporter(reporter); + isReporter[reporter] = false; + uint256 len = _reporterList.length; + for (uint256 i = 0; i < len; i++) { + if (_reporterList[i] == reporter) { + _reporterList[i] = _reporterList[len - 1]; + _reporterList.pop(); + break; + } + } + emit ReporterRemoved(reporter); + } + + function reporters() external view returns (address[] memory) { + return _reporterList; + } + + function setGuardian(address guardian_) external onlyOwner { + if (guardian_ == address(0)) revert SupplyOracle_ZeroAddress(); + guardian = guardian_; + emit GuardianSet(guardian_); + } + + /// @notice Guardian can pause; owner can unpause (pause-only powers). + function pause() external { + if (msg.sender != guardian) revert SupplyOracle_NotGuardian(msg.sender); + paused = true; + emit PausedSet(true); + } + + function unpause() external onlyOwner { + paused = false; + emit PausedSet(false); + } + + function setParams( + uint256 minReporters_, + uint256 divergenceToleranceBps_, + uint256 reportStaleAfter_, + uint256 maxCommitAge_, + uint256 maxFactorDeltaBps_ + ) external onlyOwner { + if ( + minReporters_ == 0 || minReporters_ > MAX_REPORTERS || divergenceToleranceBps_ > BPS + || reportStaleAfter_ == 0 || maxCommitAge_ < reportStaleAfter_ || maxFactorDeltaBps_ == 0 + || maxFactorDeltaBps_ > BPS + ) { + revert SupplyOracle_InvalidParams(); + } + minReporters = minReporters_; + divergenceToleranceBps = divergenceToleranceBps_; + reportStaleAfter = reportStaleAfter_; + maxCommitAge = maxCommitAge_; + maxFactorDeltaBps = maxFactorDeltaBps_; + emit ParamsSet(minReporters_, divergenceToleranceBps_, reportStaleAfter_, maxCommitAge_, maxFactorDeltaBps_); + } + + // ======================================================================== + // Internal math + // ======================================================================== + + /// @dev Insertion sort, ascending. Bounded by MAX_REPORTERS, so the + /// quadratic cost is on a set of at most 16 elements. + function _sort(uint256[] memory a) private pure { + for (uint256 i = 1; i < a.length; i++) { + uint256 key = a[i]; + uint256 j = i; + while (j > 0 && a[j - 1] > key) { + a[j] = a[j - 1]; + j--; + } + a[j] = key; + } + } + + /// @dev Median of a pre-sorted array. Even length averages the two middle + /// elements (floor), odd length takes the center. + function _median(uint256[] memory sorted) private pure returns (uint256) { + uint256 len = sorted.length; + uint256 mid = len / 2; + if (len % 2 == 1) return sorted[mid]; + return (sorted[mid - 1] + sorted[mid]) / 2; + } +} diff --git a/test/SupplyOracle.t.sol b/test/SupplyOracle.t.sol new file mode 100644 index 0000000..16f41a1 --- /dev/null +++ b/test/SupplyOracle.t.sol @@ -0,0 +1,347 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Test } from "forge-std/Test.sol"; + +import { ExcludedAddressRegistry } from "src/oracle/ExcludedAddressRegistry.sol"; +import { + ExcludedRegistry_TimelockNotElapsed, + ExcludedRegistry_NoPendingChange, + ExcludedRegistry_ChangeAlreadyPending, + ExcludedRegistry_NoOp +} from "src/oracle/ExcludedAddressRegistry.sol"; +import { SupplyOracle } from "src/oracle/SupplyOracle.sol"; +import { + SupplyOracle_Paused, + SupplyOracle_NotInitialized, + SupplyOracle_CommitTooOld, + SupplyOracle_NotEnoughFreshReports, + SupplyOracle_SourcesDiverged, + SupplyOracle_FactorAboveOne, + SupplyOracle_NotReporter, + SupplyOracle_NotGuardian +} from "src/oracle/SupplyOracle.sol"; +import { MockERC20 } from "test/mocks/MockERC20.sol"; + +contract SupplyOracleTest is Test { + uint256 internal constant WAD = 1e18; + + ExcludedAddressRegistry internal registry; + SupplyOracle internal oracle; + MockERC20 internal token; + + address internal owner = address(this); + address internal guardian = makeAddr("guardian"); + address internal repA = makeAddr("reporterA"); + address internal repB = makeAddr("reporterB"); + address internal repC = makeAddr("reporterC"); + + address internal treasury = makeAddr("treasury"); + address internal vesting = makeAddr("vesting"); + + uint256 internal constant DELAY = 2 days; + + function setUp() public { + vm.warp(60 days); + + token = new MockERC20("Token", "TKN", 18); + // 1,000,000 total: 600k circulating, 300k treasury, 100k vesting. + token.mint(makeAddr("holders"), 600_000e18); + token.mint(treasury, 300_000e18); + token.mint(vesting, 100_000e18); + + registry = new ExcludedAddressRegistry(owner, DELAY); + oracle = new SupplyOracle(registry, guardian, owner); + + oracle.addReporter(repA); + oracle.addReporter(repB); + oracle.addReporter(repC); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + function _exclude(address account) internal { + registry.proposeChange(address(token), account, true); + vm.warp(block.timestamp + DELAY); + registry.executeChange(address(token), account, true); + } + + function _reportAll(uint256 a, uint256 b, uint256 c) internal { + vm.prank(repA); + oracle.report(address(token), a); + vm.prank(repB); + oracle.report(address(token), b); + vm.prank(repC); + oracle.report(address(token), c); + } + + // ======================================================================== + // Layer 1: on-chain derivation and the timelock + // ======================================================================== + + function test_OnChainCirculating_FullSupplyWhenNoExclusions() public view { + assertEq(registry.onChainCirculating(address(token)), 1_000_000); + } + + function test_OnChainCirculating_SubtractsExcluded() public { + _exclude(treasury); + assertEq(registry.onChainCirculating(address(token)), 700_000); + _exclude(vesting); + assertEq(registry.onChainCirculating(address(token)), 600_000); + } + + function test_OnChainCirculating_TracksLiveBalances() public { + _exclude(treasury); + // Treasury unlocks 100k into circulation. + vm.prank(treasury); + token.transfer(makeAddr("market"), 100_000e18); + assertEq(registry.onChainCirculating(address(token)), 800_000); + } + + function test_Timelock_CannotExecuteEarly() public { + registry.proposeChange(address(token), treasury, true); + bytes32 id = registry.changeId(address(token), treasury, true); + (uint64 eta,,) = registry.pendingChanges(id); + + vm.expectRevert(abi.encodeWithSelector(ExcludedRegistry_TimelockNotElapsed.selector, id, eta)); + registry.executeChange(address(token), treasury, true); + + vm.warp(eta); + registry.executeChange(address(token), treasury, true); + assertTrue(registry.isExcluded(address(token), treasury)); + } + + function test_Timelock_ExecuteIsPermissionless() public { + registry.proposeChange(address(token), treasury, true); + vm.warp(block.timestamp + DELAY); + // A non-owner finalizes a change the owner already committed to. + vm.prank(makeAddr("anyone")); + registry.executeChange(address(token), treasury, true); + assertTrue(registry.isExcluded(address(token), treasury)); + } + + function test_Timelock_CancelStopsExecution() public { + registry.proposeChange(address(token), treasury, true); + registry.cancelChange(address(token), treasury, true); + bytes32 id = registry.changeId(address(token), treasury, true); + + vm.warp(block.timestamp + DELAY); + vm.expectRevert(abi.encodeWithSelector(ExcludedRegistry_NoPendingChange.selector, id)); + registry.executeChange(address(token), treasury, true); + } + + function test_Timelock_RejectsRedundantAndDuplicate() public { + registry.proposeChange(address(token), treasury, true); + bytes32 id = registry.changeId(address(token), treasury, true); + vm.expectRevert(abi.encodeWithSelector(ExcludedRegistry_ChangeAlreadyPending.selector, id)); + registry.proposeChange(address(token), treasury, true); + + vm.warp(block.timestamp + DELAY); + registry.executeChange(address(token), treasury, true); + + // Excluding an already-excluded address is a no-op. + vm.expectRevert(abi.encodeWithSelector(ExcludedRegistry_NoOp.selector, address(token), treasury, true)); + registry.proposeChange(address(token), treasury, true); + } + + function test_Timelock_RemovalPath() public { + _exclude(treasury); + registry.proposeChange(address(token), treasury, false); + vm.warp(block.timestamp + DELAY); + registry.executeChange(address(token), treasury, false); + assertFalse(registry.isExcluded(address(token), treasury)); + assertEq(registry.excludedCount(address(token)), 0); + } + + // ======================================================================== + // Layer 2: report, commit, median + // ======================================================================== + + function test_Commit_MedianOfFreshReports() public { + _exclude(treasury); + _exclude(vesting); // circulating = 600,000 + // Reports cluster within the 2% tolerance; median is the middle value. + _reportAll(0.91e18, 0.92e18, 0.93e18); + oracle.commit(address(token)); + + (uint256 factor,, bool frozen) = oracle.freeFloatFactor(address(token)); + assertEq(factor, 0.92e18, "median not committed"); + assertFalse(frozen); + + // free-float = 600,000 * 0.92 = 552,000 + assertEq(oracle.getFreeFloatSupply(address(token)), 552_000); + } + + function test_Commit_RevertsBelowQuorum() public { + vm.prank(repA); + oracle.report(address(token), 0.9e18); + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_NotEnoughFreshReports.selector, address(token), 1, 2)); + oracle.commit(address(token)); + } + + function test_Commit_IgnoresStaleReports() public { + _reportAll(0.9e18, 0.9e18, 0.9e18); + // Age every report past the freshness window. + vm.warp(block.timestamp + oracle.reportStaleAfter() + 1); + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_NotEnoughFreshReports.selector, address(token), 0, 2)); + oracle.commit(address(token)); + } + + function test_Report_RejectsFactorAboveOne() public { + vm.prank(repA); + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_FactorAboveOne.selector, WAD + 1)); + oracle.report(address(token), WAD + 1); + } + + function test_Report_OnlyReporter() public { + vm.prank(makeAddr("rando")); + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_NotReporter.selector, makeAddr("rando"))); + oracle.report(address(token), 0.9e18); + } + + // ======================================================================== + // Layer 2: divergence freeze (adversarial) + // ======================================================================== + + /// @notice When the reporter set genuinely splits (no quorum agrees within + /// tolerance), the commit reverts and the constituent freezes at last-good. + function test_DivergenceFreeze_NoQuorumKeepsLastGood() public { + _exclude(treasury); + _exclude(vesting); + _reportAll(0.91e18, 0.92e18, 0.93e18); + oracle.commit(address(token)); + assertEq(oracle.getFreeFloatSupply(address(token)), 600_000 * 92 / 100); + + // All three reporters now disagree wildly: median 0.60, and neither + // 0.30 nor 0.92 sits within the 2% band, so fewer than two agree. + vm.warp(block.timestamp + 1 hours); + _reportAll(0.3e18, 0.6e18, 0.92e18); + // spread 0.62 over median 0.60 = 10334 bps, far past the 200 tolerance. + vm.expectRevert( + abi.encodeWithSelector(SupplyOracle_SourcesDiverged.selector, address(token), uint256(10_334), 200) + ); + oracle.commit(address(token)); + + // Last-good factor untouched: the freeze held. + (uint256 factor,,) = oracle.freeFloatFactor(address(token)); + assertEq(factor, 0.92e18); + } + + /// @notice The median is robust to a single captured reporter: a 2-of-3 + /// honest majority within tolerance commits, the outlier is outvoted. + function test_DivergenceFreeze_SingleOutlierOutvoted() public { + _exclude(treasury); + _exclude(vesting); + // Two honest at ~0.90, one captured reporter high. Median = 0.90, + // two reports within the 2% band, so quorum holds and the outlier + // never moves the committed value. + _reportAll(0.899e18, 0.9e18, 0.99e18); + oracle.commit(address(token)); + (uint256 factor,,) = oracle.freeFloatFactor(address(token)); + assertEq(factor, 0.9e18); + } + + // ======================================================================== + // Layer 3: rate-limit clamp, hard staleness, pause + // ======================================================================== + + /// @notice A large but agreed move is clamped per commit and converges + /// over several commits, so a malicious spike cannot move the index at once. + function test_RateLimit_ClampsAndConverges() public { + _exclude(treasury); + _exclude(vesting); + _reportAll(0.9e18, 0.9e18, 0.9e18); + oracle.commit(address(token)); + + // Reporters now all agree on a 50% drop, far beyond the 10% step. + uint256 step = oracle.maxFactorDeltaBps(); + for (uint256 i = 0; i < 8; i++) { + vm.warp(block.timestamp + 1 hours); + _reportAll(0.45e18, 0.45e18, 0.45e18); + (uint256 before,,) = oracle.freeFloatFactor(address(token)); + oracle.commit(address(token)); + (uint256 afterFactor,,) = oracle.freeFloatFactor(address(token)); + + uint256 maxStep = before * step / 10_000; + // Each commit moves by at most one clamped step. + assertLe(before - afterFactor, maxStep + 1, "moved more than one step"); + } + // After enough steps it has converged near the target, not overshot it. + (uint256 finalFactor,,) = oracle.freeFloatFactor(address(token)); + assertGe(finalFactor, 0.45e18); + assertLt(finalFactor, 0.6e18); + } + + function test_HardStaleness_ReadRevertsPastMaxCommitAge() public { + _reportAll(0.9e18, 0.9e18, 0.9e18); + oracle.commit(address(token)); + (, uint256 committedAt,) = oracle.freeFloatFactor(address(token)); + + vm.warp(committedAt + oracle.maxCommitAge() + 1); + vm.expectRevert( + abi.encodeWithSelector( + SupplyOracle_CommitTooOld.selector, address(token), committedAt, oracle.maxCommitAge() + ) + ); + oracle.getFreeFloatSupply(address(token)); + } + + function test_SoftStaleness_ServesLastGoodInsideCeiling() public { + _reportAll(0.9e18, 0.9e18, 0.9e18); + oracle.commit(address(token)); + + // Past the report-fresh window but well inside maxCommitAge: frozen + // flag is set for dashboards, but the read still serves last-good. + vm.warp(block.timestamp + oracle.reportStaleAfter() + 1); + (,, bool frozen) = oracle.freeFloatFactor(address(token)); + assertTrue(frozen); + assertEq(oracle.getFreeFloatSupply(address(token)), 1_000_000 * 9 / 10); + } + + function test_Read_RevertsBeforeInitialization() public { + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_NotInitialized.selector, address(token))); + oracle.getFreeFloatSupply(address(token)); + } + + function test_GuardianPause_FailsReadsClosed() public { + _reportAll(0.9e18, 0.9e18, 0.9e18); + oracle.commit(address(token)); + + vm.prank(guardian); + oracle.pause(); + vm.expectRevert(SupplyOracle_Paused.selector); + oracle.getFreeFloatSupply(address(token)); + + // Commits also halt while paused. + vm.expectRevert(SupplyOracle_Paused.selector); + oracle.commit(address(token)); + + oracle.unpause(); + assertEq(oracle.getFreeFloatSupply(address(token)), 1_000_000 * 9 / 10); + } + + function test_GuardianPause_OnlyGuardian() public { + vm.prank(makeAddr("rando")); + vm.expectRevert(abi.encodeWithSelector(SupplyOracle_NotGuardian.selector, makeAddr("rando"))); + oracle.pause(); + } + + // ======================================================================== + // Sanity invariant + // ======================================================================== + + /// @notice free-float can never exceed on-chain circulating, because the + /// factor is capped at WAD. Holds for any agreed report and exclusion set. + function testFuzz_FreeFloatNeverExceedsCirculating(uint256 factorSeed, bool excludeTreasury) public { + uint256 factor = bound(factorSeed, 1, WAD); + if (excludeTreasury) _exclude(treasury); + + _reportAll(factor, factor, factor); + oracle.commit(address(token)); + + uint256 circulating = registry.onChainCirculating(address(token)); + assertLe(oracle.getFreeFloatSupply(address(token)), circulating); + } +} diff --git a/test/SupplyOracleIntegration.t.sol b/test/SupplyOracleIntegration.t.sol new file mode 100644 index 0000000..444409f --- /dev/null +++ b/test/SupplyOracleIntegration.t.sol @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Test } from "forge-std/Test.sol"; + +import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { ExcludedAddressRegistry } from "src/oracle/ExcludedAddressRegistry.sol"; +import { SupplyOracle } from "src/oracle/SupplyOracle.sol"; +import { MarketCapMethodology } from "src/methodology/MarketCapMethodology.sol"; +import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; +import { MockERC20 } from "test/mocks/MockERC20.sol"; +import { MockAggregator } from "test/mocks/MockAggregator.sol"; + +/// @notice Proves the ISupplyOracle seam: the real layered SupplyOracle drives +/// MarketCapMethodology end to end, with no mock supply source in the path. +contract SupplyOracleIntegrationTest is Test { + uint256 internal constant WAD = 1e18; + uint48 internal constant HEARTBEAT = 1 days; + uint256 internal constant DELAY = 2 days; + + ComponentRegistry internal components; + ExcludedAddressRegistry internal excluded; + SupplyOracle internal oracle; + MarketCapMethodology internal methodology; + + MockERC20 internal wbtc; + MockERC20 internal weth; + MockERC20 internal tailA; + MockERC20 internal tailB; + + MockAggregator[4] internal feeds; + + address[] internal tokens; + address internal guardian = makeAddr("guardian"); + address internal repA = makeAddr("repA"); + address internal repB = makeAddr("repB"); + + function setUp() public { + vm.warp(60 days); + + wbtc = new MockERC20("Wrapped BTC", "WBTC", 8); + weth = new MockERC20("Wrapped Ether", "WETH", 18); + tailA = new MockERC20("Tail A", "TLA", 18); + tailB = new MockERC20("Tail B", "TLB", 18); + + // Total supplies (native decimals). All circulating, free-float = 1.0 + // unless an exclusion or factor below 1 is applied. + wbtc.mint(makeAddr("btcHolders"), 20_000_000e8); // 20M BTC + weth.mint(makeAddr("ethHolders"), 120_000_000e18); // 120M ETH + // Split tailA so a vesting tranche can be excluded without zeroing it. + tailA.mint(makeAddr("tlaHolders"), 500_000_000e18); + tailA.mint(makeAddr("tlaVesting"), 500_000_000e18); + tailB.mint(makeAddr("tlbHolders"), 1_000_000_000e18); // 1B + + feeds[0] = new MockAggregator(8, 100_000e8); + feeds[1] = new MockAggregator(8, 5_000e8); + feeds[2] = new MockAggregator(8, 2e8); + feeds[3] = new MockAggregator(8, 2e8); + + components = new ComponentRegistry(address(this)); + components.registerComponent(address(wbtc), address(feeds[0]), HEARTBEAT); + components.registerComponent(address(weth), address(feeds[1]), HEARTBEAT); + components.registerComponent(address(tailA), address(feeds[2]), HEARTBEAT); + components.registerComponent(address(tailB), address(feeds[3]), HEARTBEAT); + + excluded = new ExcludedAddressRegistry(address(this), DELAY); + oracle = new SupplyOracle(excluded, guardian, address(this)); + oracle.addReporter(repA); + oracle.addReporter(repB); + + methodology = new MarketCapMethodology(components, ISupplyOracle(address(oracle)), address(this)); + + tokens.push(address(wbtc)); + tokens.push(address(weth)); + tokens.push(address(tailA)); + tokens.push(address(tailB)); + + // Seed every constituent's free-float factor at 1.0 and commit. + _reportAndCommitAll(WAD); + } + + /// @dev Re-stamps every price feed to now, undoing staleness from a warp. + function _refreshFeeds() internal { + feeds[0].setAnswer(100_000e8); + feeds[1].setAnswer(5_000e8); + feeds[2].setAnswer(2e8); + feeds[3].setAnswer(2e8); + } + + function _reportAndCommitAll(uint256 factor) internal { + for (uint256 i = 0; i < tokens.length; i++) { + vm.prank(repA); + oracle.report(tokens[i], factor); + vm.prank(repB); + oracle.report(tokens[i], factor); + oracle.commit(tokens[i]); + } + } + + function test_WeightsComputeThroughRealOracle() public view { + uint256[] memory w = methodology.getWeights(tokens); + + // BTC ($2T) and ETH ($600B) dominate and pin at the 25% cap; the two + // tails ($2B each) split the remainder. + assertEq(w[0], 0.25e18, "BTC not at cap"); + assertEq(w[1], 0.25e18, "ETH not at cap"); + uint256 sum; + for (uint256 i = 0; i < 4; i++) { + sum += w[i]; + } + assertEq(sum, WAD); + } + + /// @notice Section 8.4: a capped constituent's weight is independent of its + /// supply-oracle value. Move BTC's free-float factor through the full + /// committed range and its weight never leaves the cap. + function test_CapNeutralizesSupplyOracleOnLargeConstituent() public { + uint256[] memory before = methodology.getWeights(tokens); + + // Drive BTC's factor down 10% (clamped one step) repeatedly. + for (uint256 i = 0; i < 5; i++) { + vm.warp(block.timestamp + 1 hours); + vm.prank(repA); + oracle.report(address(wbtc), 0.5e18); + vm.prank(repB); + oracle.report(address(wbtc), 0.5e18); + oracle.commit(address(wbtc)); + } + + uint256[] memory afterW = methodology.getWeights(tokens); + assertEq(before[0], afterW[0], "capped BTC weight moved with supply"); + assertEq(afterW[0], 0.25e18); + } + + /// @notice A free-float exclusion on a tail name (a vesting contract found + /// and timelocked in) flows through to a lower weight for that name. + function test_ExclusionLowersTailWeight() public { + // Raise the cap so the 4-name index is not saturated (at 25% every + // name pins to the cap and no supply change can move a weight); at 40% + // the tails sit below the cap and supply flows through. + methodology.setWeightParams(0.4e18, 1e14); + uint256[] memory before = methodology.getWeights(tokens); + + // Exclude tailA's vesting tranche (half its supply) once identified. + address vestingTLA = makeAddr("tlaVesting"); + excluded.proposeChange(address(tailA), vestingTLA, true); + vm.warp(block.timestamp + DELAY); + excluded.executeChange(address(tailA), vestingTLA, true); + _refreshFeeds(); // the timelock warp outran the 1-day price heartbeat + + // tailA's circulating supply halves, so its market cap and weight fall + // while the name stays in the index. + uint256[] memory afterW = methodology.getWeights(tokens); + assertLt(afterW[2], before[2], "tailA weight did not fall"); + assertGt(afterW[2], 0, "tailA wrongly dropped"); + } + + /// @notice A guardian pause on the supply oracle halts weight computation, + /// which is what stops a rebalance under suspicious supply data. + function test_GuardianPauseHaltsMethodology() public { + vm.prank(guardian); + oracle.pause(); + vm.expectRevert(); + methodology.getWeights(tokens); + } +}