From a833a70275bf8654a834318c3aa826e853740d4f Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 19 Jun 2026 16:46:54 -0600 Subject: [PATCH] chore(deploy): config-driven price-oracle bring-up script + runbook + config block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the ops/deploy gap behind the oracle audit fixes: the price oracle is real and consumed (Tangle payments/exposure + ServiceFeeDistributor), but FullDeploy only WIRES a pre-existing address — it never deploys or configures one, and base-mainnet.json left it unset, so the protocol launches on the raw-token-amount fallback. That's intentional at genesis (exposure-weighted rail dormant, stakersBps=0), but there was no scripted/runbooked path to turn USD normalization on. - script/ConfigureOracle.s.sol: config-driven (reads the `oracle` block of FULL_DEPLOY_CONFIG, mirroring FullDeploy/DeployGovernance conventions). Deploys a ChainlinkOracle or UniswapV3Oracle, configures every feed/pool + maxAge + the L2 sequencer-uptime gate, optionally wires Tangle + ServiceFeeDistributor (setPriceOracle, bootstrap window only), and hands the oracle's Ownable ownership to the timelock/multisig as the final step. Production guard (bypass with TANGLE_DEPLOY_LOCAL=1) requires owner set, maxAge set, >=1 feed/pool, and the sequencer feed on Base/OP/Arbitrum. Writes a manifest; prints the governance setPriceOracle calls when wire=false. - deploy/RUNBOOK-launch.md §3c: oracle bring-up procedure + bootstrap-vs-governance wiring + the stakersBps flip. - deploy/config/base-mainnet.json: self-documenting `oracle` block (deploy=false at genesis; Base sequencer feed pre-filled; TODOs for feeds/owner). No src changes. Script compiles and dry-runs end-to-end (deploy + configure + manifest); base-mainnet.json validated. --- deploy/RUNBOOK-launch.md | 33 ++++ deploy/config/base-mainnet.json | 19 ++ script/ConfigureOracle.s.sol | 306 ++++++++++++++++++++++++++++++++ 3 files changed, 358 insertions(+) create mode 100644 script/ConfigureOracle.s.sol diff --git a/deploy/RUNBOOK-launch.md b/deploy/RUNBOOK-launch.md index 16448f5..0a5180d 100644 --- a/deploy/RUNBOOK-launch.md +++ b/deploy/RUNBOOK-launch.md @@ -72,6 +72,39 @@ them (by design): 5. (Hyperlane/LayerZero only) pin the **ISM** / **DVN+executor** out-of-band before activation. Not needed for OP-Stack. +## 3c. Price oracle bring-up (turn ON USD normalization) — optional, deferred at genesis +The protocol ships with **no price oracle wired** (`incentives.priceOracle` unset). With no oracle, +`PaymentsDistribution` / `PaymentsBilling` / `PaymentsEffectiveExposure` / `ServiceFeeDistributor` +all fall back to **raw token amounts** — correct for a single homogeneous asset, but it mis-weights +payouts/exposure across heterogeneous assets. This is intentional at launch: the exposure-weighted +rail is dormant (`incentives.weights.stakersBps = 0`). Do this step only when you turn that rail on. + +`FullDeploy` never deploys or configures an oracle — it only wires a pre-existing address. Use +`script/ConfigureOracle.s.sol`, which deploys a `ChainlinkOracle` or `UniswapV3Oracle`, configures +every feed/pool + staleness + the L2 sequencer gate, optionally wires the two consumers, and hands +the oracle's `Ownable` ownership to the timelock/multisig. It reads the `oracle` block of the deploy +config (see `deploy/config/base-mainnet.json`). + +``` +PRIVATE_KEY= FULL_DEPLOY_CONFIG=deploy/config/base-mainnet.json \ + forge script script/ConfigureOracle.s.sol:ConfigureOracle --rpc-url "$L2_RPC" --broadcast --slow +``` + +Wiring (`Tangle.setPriceOracle` + `ServiceFeeDistributor.setPriceOracle`) needs ADMIN_ROLE: +- During the bootstrap window (before role handoff) set `oracle.wire=true` and the deployer wires it. +- After handoff, leave `oracle.wire=false` and execute the two `setPriceOracle` calls the script + prints **through governance** (the timelock). + +Required for a production run (the script's `_requireProductionConfig` enforces these, bypass on +anvil with `TANGLE_DEPLOY_LOCAL=1`): +- `oracle.owner` = timelock/multisig (the oracle must not stay EOA-owned), +- `oracle.maxAgeSeconds` > 0, and at least one feed (chainlink) / pool (uniswap), +- on Base/OP/Arbitrum, `oracle.sequencerUptimeFeed` set (Base: `0xBCF85224fc0756B9Fa45aA7892530B47e10b6433`) + so the oracle cannot serve frozen prices during a sequencer outage. + +Then flip `incentives.weights.stakersBps` to a non-zero value via a governance migration (pre-commit +to e.g. staking 3500 / stakers 1500, per the `incentives._note_weights` in the config). + ## 4. Verification after launch - `forge test` (or CI `Foundry CI` workflow) green; facet sizes within EIP-170 (the `size` CI job). - `_assertGovernanceConfiguration` runs inside `FullDeploy` (roles handed to timelock/multisig, diff --git a/deploy/config/base-mainnet.json b/deploy/config/base-mainnet.json index 05bf7af..96078a6 100644 --- a/deploy/config/base-mainnet.json +++ b/deploy/config/base-mainnet.json @@ -248,6 +248,25 @@ "_note": "Deploys LiquidDelegationFactory once (staking from core manifest); vaults are created on demand. Owner handed to timelock.", "owner": "0x0000000000000000000000000000000000000000" }, + "oracle": { + "deploy": false, + "_note": "Price-oracle bring-up for USD normalization, consumed by script/ConfigureOracle.s.sol (NOT by FullDeploy). deploy=false at genesis: with no oracle the payment/exposure rails use raw token amounts and the exposure-weighted rail is dormant (incentives.weights.stakersBps=0). Set deploy=true ONLY when turning that rail on, then flip stakersBps via governance. See deploy/RUNBOOK-launch.md section 3c.", + "kind": "chainlink", + "_todo_nativeFeed": "TODO: Base ETH/USD Chainlink feed", + "nativeFeed": "0x0000000000000000000000000000000000000000", + "maxAgeSeconds": 3600, + "_note_sequencer": "Base canonical L2 sequencer-uptime feed. REQUIRED on Base (the prod guard enforces it) so the oracle cannot serve frozen prices during a sequencer outage.", + "sequencerUptimeFeed": "0xBCF85224fc0756B9Fa45aA7892530B47e10b6433", + "sequencerGracePeriodSeconds": 3600, + "_todo_owner": "TODO: set to roles.timelock (oracle Ownable ownership is handed off as the final step).", + "owner": "0x0000000000000000000000000000000000000000", + "_note_wire": "wire=true only during the bootstrap window (deployer still holds ADMIN_ROLE). After role handoff, leave false and run the printed setPriceOracle calls through governance.", + "wire": false, + "tangle": "0x0000000000000000000000000000000000000000", + "serviceFeeDistributor": "0x0000000000000000000000000000000000000000", + "_note_feeds": "chainlink: one entry per priced ERC20 (token -> token/USD Chainlink feed). For kind=uniswap use a `pools` array of { token, pool, quoteFeed, quoteIsUsd } instead.", + "feeds": [] + }, "beacon": { "deploy": false, "_note": "Native-ETH restaking slash bridge. L1 (Ethereum) leg: ValidatorPodManager + EIP4788 oracle + L2SlashingConnector + BaseCrossChainMessenger. bridge=opstack uses Base's canonical L1CrossDomainMessenger — NATIVE, no third-party ISM/DVN to pin. Set deploy=true only when admin/oracle are real. Never enable USE_MOCK_BEACON_ORACLE on mainnet.", diff --git a/script/ConfigureOracle.s.sol b/script/ConfigureOracle.s.sol new file mode 100644 index 0000000..151230c --- /dev/null +++ b/script/ConfigureOracle.s.sol @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import { Script, console2 } from "forge-std/Script.sol"; +import { stdJson } from "forge-std/StdJson.sol"; + +import { ChainlinkOracle } from "../src/oracles/ChainlinkOracle.sol"; +import { UniswapV3Oracle } from "../src/oracles/UniswapV3Oracle.sol"; + +/// @notice Minimal view of the two consumers that hold a price-oracle pointer. Both `Tangle` +/// (`Base.setPriceOracle`, ADMIN_ROLE) and `ServiceFeeDistributor` (`setPriceOracle`, +/// ADMIN_ROLE) expose this exact selector. +interface ISetPriceOracle { + function setPriceOracle(address oracle) external; +} + +/// @notice Ownable surface for the post-config ownership handoff. +interface IOwnableTransfer { + function transferOwnership(address newOwner) external; +} + +/// @title ConfigureOracle +/// @notice Config-driven bring-up for the price oracle: deploys a `ChainlinkOracle` or +/// `UniswapV3Oracle`, configures every feed/pool + staleness + L2 sequencer gate, optionally +/// wires it into `Tangle` and `ServiceFeeDistributor`, and hands the oracle's `Ownable` +/// ownership to the timelock/multisig. +/// +/// @dev WHY THIS EXISTS. `FullDeploy` only *wires* a pre-existing oracle address +/// (`incentives.priceOracle`); it never deploys one and never configures feeds. With no oracle +/// set, every consumer (`PaymentsDistribution`, `PaymentsBilling`, `PaymentsEffectiveExposure`, +/// `ServiceFeeDistributor`) falls back to RAW token amounts — fine for a single homogeneous +/// asset, but mis-weights heterogeneous assets. This script is the missing post-deploy step +/// that turns USD normalization on. +/// +/// ORDERING. Run AFTER `FullDeploy` (needs the `tangle`/`serviceFeeDistributor` addresses to +/// wire). On mainnet the wiring calls (`setPriceOracle`) require ADMIN_ROLE, so either run with +/// `oracle.wire=true` DURING the bootstrap window (before role handoff, deployer still holds +/// ADMIN_ROLE) or leave `wire=false` and execute the two printed `setPriceOracle` calls through +/// governance. The oracle's own ownership is always handed to `oracle.owner` (timelock/multisig) +/// as the final step. +/// +/// CONFIG (`.oracle`): +/// { +/// "deploy": true, +/// "kind": "chainlink", // or "uniswap" +/// "weth": "0x...", // uniswap only (constructor arg / native quote) +/// "nativeFeed": "0x...", // chainlink: ETH/USD feed (constructor); uniswap: native USD feed +/// "maxAgeSeconds": 3600, +/// "sequencerUptimeFeed": "0x...", // Base: 0xBCF85224fc0756B9Fa45aA7892530B47e10b6433; 0x0 on L1 +/// "sequencerGracePeriodSeconds": 3600, +/// "twapPeriodSeconds": 1800, // uniswap only +/// "owner": "0x...", // timelock/multisig to receive Ownable ownership +/// "wire": false, // also call Tangle/SFD setPriceOracle (bootstrap only) +/// "tangle": "0x...", // required if wire +/// "serviceFeeDistributor": "0x...", // optional if wire +/// "feeds": [ { "token": "0x..", "feed": "0x.." } ], // chainlink +/// "pools": [ { "token": "0x..", "pool": "0x..", "quoteFeed": "0x..", "quoteIsUsd": false } ] // uniswap +/// } +/// +/// Usage: +/// PRIVATE_KEY= FULL_DEPLOY_CONFIG=deploy/config/base-mainnet.json \ +/// forge script script/ConfigureOracle.s.sol:ConfigureOracle --rpc-url --broadcast --slow +/// +/// Local/anvil bypass of the production guards: TANGLE_DEPLOY_LOCAL=1. +contract ConfigureOracle is Script { + using stdJson for string; + + struct OracleConfig { + bool deploy; + string kind; // "chainlink" | "uniswap" + address weth; + address nativeFeed; + uint256 maxAgeSeconds; + address sequencerUptimeFeed; + uint256 sequencerGracePeriodSeconds; + uint32 twapPeriodSeconds; + address owner; + bool wire; + address tangle; + address serviceFeeDistributor; + } + + function run() external { + uint256 deployerKey = _requireUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + string memory blob = _readConfig(); + OracleConfig memory c = _loadOracleConfig(blob); + + require(c.deploy, "oracle.deploy=false - nothing to do"); + bool isChainlink = _isChainlink(c.kind); + require(isChainlink || _isUniswap(c.kind), "oracle.kind must be 'chainlink' or 'uniswap'"); + + _requireProductionConfig(c, isChainlink, blob); + + console2.log("=== Configure Price Oracle ==="); + console2.log("ChainId:", block.chainid); + console2.log("Deployer:", deployer); + console2.log("Kind:", c.kind); + + vm.startBroadcast(deployerKey); + + address oracle = isChainlink ? _deployChainlink(c, blob) : _deployUniswap(c, blob); + + // Shared config (both oracles expose these selectors). + if (c.maxAgeSeconds != 0) { + ChainlinkOracle(oracle).setMaxPriceAge(c.maxAgeSeconds); // same selector on both + } + if (c.sequencerUptimeFeed != address(0)) { + // setSequencerUptimeFeed requires a non-zero grace period. + uint256 grace = c.sequencerGracePeriodSeconds == 0 ? 1 hours : c.sequencerGracePeriodSeconds; + ChainlinkOracle(oracle).setSequencerUptimeFeed(c.sequencerUptimeFeed, grace); + console2.log("Sequencer uptime feed set:", c.sequencerUptimeFeed); + } + + // Optional wiring into the consumers (bootstrap window only; needs ADMIN_ROLE). + if (c.wire) { + require(c.tangle != address(0), "oracle.wire=true requires oracle.tangle"); + ISetPriceOracle(c.tangle).setPriceOracle(oracle); + console2.log("Wired Tangle.setPriceOracle ->", oracle); + if (c.serviceFeeDistributor != address(0)) { + ISetPriceOracle(c.serviceFeeDistributor).setPriceOracle(oracle); + console2.log("Wired ServiceFeeDistributor.setPriceOracle ->", oracle); + } + } + + // Final step: hand the oracle's Ownable ownership to the timelock/multisig. + if (c.owner != address(0) && c.owner != deployer) { + IOwnableTransfer(oracle).transferOwnership(c.owner); + console2.log("Oracle ownership transferred to:", c.owner); + } + + vm.stopBroadcast(); + + console2.log("Oracle deployed:", oracle); + _writeManifest(oracle, c.kind); + + if (!c.wire) { + console2.log("--- WIRE VIA GOVERNANCE (oracle.wire was false) ---"); + console2.log(" Tangle.setPriceOracle(", oracle, ")"); + console2.log(" ServiceFeeDistributor.setPriceOracle(", oracle, ")"); + } + } + + // ── deployers + // ────────────────────────────────────────────────────────────── + + function _deployChainlink(OracleConfig memory c, string memory blob) internal returns (address) { + ChainlinkOracle oracle = new ChainlinkOracle(c.nativeFeed); // native feed set in constructor + + uint256 n = _arrayLen(blob, ".oracle.feeds", ".token"); + for (uint256 i = 0; i < n; i++) { + string memory base = string.concat(".oracle.feeds[", vm.toString(i), "]"); + address token = blob.readAddress(string.concat(base, ".token")); + address feed = blob.readAddress(string.concat(base, ".feed")); + oracle.configurePriceFeed(token, feed); + console2.log(" feed configured token/feed:", token, feed); + } + return address(oracle); + } + + function _deployUniswap(OracleConfig memory c, string memory blob) internal returns (address) { + require(c.weth != address(0), "uniswap: oracle.weth required"); + UniswapV3Oracle oracle = new UniswapV3Oracle(c.weth); + + if (c.twapPeriodSeconds != 0) { + oracle.setTwapPeriod(c.twapPeriodSeconds); + } + if (c.nativeFeed != address(0)) { + oracle.setNativeTokenFeed(c.nativeFeed); // quoteTokenFeeds[address(0)] + } + + uint256 n = _arrayLen(blob, ".oracle.pools", ".token"); + for (uint256 i = 0; i < n; i++) { + string memory base = string.concat(".oracle.pools[", vm.toString(i), "]"); + address token = blob.readAddress(string.concat(base, ".token")); + address pool = blob.readAddress(string.concat(base, ".pool")); + address quoteFeed = blob.keyExists(string.concat(base, ".quoteFeed")) + ? blob.readAddress(string.concat(base, ".quoteFeed")) + : address(0); + bool quoteIsUsd = + blob.keyExists(string.concat(base, ".quoteIsUsd")) && blob.readBool(string.concat(base, ".quoteIsUsd")); + oracle.configurePool(token, pool, quoteFeed, quoteIsUsd); + console2.log(" pool configured token/pool:", token, pool); + } + return address(oracle); + } + + // ── config loading + // ────────────────────────────────────────────────────────── + + function _readConfig() internal view returns (string memory blob) { + string memory path = vm.envString("FULL_DEPLOY_CONFIG"); + blob = vm.readFile(path); + require(bytes(blob).length != 0, "empty FULL_DEPLOY_CONFIG"); + require(blob.keyExists(".oracle"), "config has no `oracle` block"); + } + + function _loadOracleConfig(string memory blob) internal view returns (OracleConfig memory c) { + c.deploy = blob.keyExists(".oracle.deploy") && blob.readBool(".oracle.deploy"); + c.kind = blob.keyExists(".oracle.kind") ? blob.readString(".oracle.kind") : ""; + if (blob.keyExists(".oracle.weth")) c.weth = blob.readAddress(".oracle.weth"); + if (blob.keyExists(".oracle.nativeFeed")) c.nativeFeed = blob.readAddress(".oracle.nativeFeed"); + if (blob.keyExists(".oracle.maxAgeSeconds")) c.maxAgeSeconds = blob.readUint(".oracle.maxAgeSeconds"); + if (blob.keyExists(".oracle.sequencerUptimeFeed")) { + c.sequencerUptimeFeed = blob.readAddress(".oracle.sequencerUptimeFeed"); + } + if (blob.keyExists(".oracle.sequencerGracePeriodSeconds")) { + c.sequencerGracePeriodSeconds = blob.readUint(".oracle.sequencerGracePeriodSeconds"); + } + if (blob.keyExists(".oracle.twapPeriodSeconds")) { + c.twapPeriodSeconds = uint32(blob.readUint(".oracle.twapPeriodSeconds")); + } + if (blob.keyExists(".oracle.owner")) c.owner = blob.readAddress(".oracle.owner"); + c.wire = blob.keyExists(".oracle.wire") && blob.readBool(".oracle.wire"); + if (blob.keyExists(".oracle.tangle")) c.tangle = blob.readAddress(".oracle.tangle"); + if (blob.keyExists(".oracle.serviceFeeDistributor")) { + c.serviceFeeDistributor = blob.readAddress(".oracle.serviceFeeDistributor"); + } + } + + /// @dev Count a JSON array by probing `[i]` until it stops existing — the same + /// pattern `FullDeploy._loadVaults` uses (avoids fragile parseRaw/abi.decode struct layout). + function _arrayLen( + string memory blob, + string memory path, + string memory probeKey + ) + internal + view + returns (uint256 n) + { + while (blob.keyExists(string.concat(path, "[", vm.toString(n), "]", probeKey))) { + n++; + } + } + + // ── production guards + // ───────────────────────────────────────────────────────── + + /// @dev Refuse footguns on a production chain: an oracle whose ownership stays with the EOA + /// deployer, a missing native/quote source, or a sequencer gate omitted on a known L2. + /// Bypass on anvil/local with TANGLE_DEPLOY_LOCAL=1. + function _requireProductionConfig(OracleConfig memory c, bool isChainlink, string memory blob) internal view { + if (!_isProductionChain()) return; + + require(c.owner != address(0), "prod: oracle.owner (timelock/multisig) must be set"); + require(c.maxAgeSeconds != 0, "prod: oracle.maxAgeSeconds must be set"); + + if (isChainlink) { + require( + c.nativeFeed != address(0) || _arrayLen(blob, ".oracle.feeds", ".token") > 0, "prod: no chainlink feeds" + ); + } else { + require(_arrayLen(blob, ".oracle.pools", ".token") > 0, "prod: no uniswap pools"); + } + + // Base / Optimism / Arbitrum are L2s with a canonical sequencer-uptime feed; require it so the + // oracle cannot serve frozen prices during a sequencer outage. + uint256 id = block.chainid; + if (id == 8453 || id == 10 || id == 42_161) { + require(c.sequencerUptimeFeed != address(0), "prod L2: oracle.sequencerUptimeFeed must be set"); + } + if (c.wire) { + require(c.tangle != address(0), "prod: oracle.wire=true requires oracle.tangle"); + } + } + + function _isProductionChain() internal view returns (bool) { + if (vm.envOr("TANGLE_DEPLOY_LOCAL", uint256(0)) != 0) return false; + uint256 id = block.chainid; + return id == 1 || id == 8453 || id == 5845 || id == 42_161 || id == 10; + } + + // ── manifest + // ────────────────────────────────────────────────────────────────── + + function _writeManifest(address oracle, string memory kind) internal { + string memory outPath = vm.envOr("ORACLE_MANIFEST", string("deployments/oracle.json")); + string memory manifest = "oracle"; + manifest.serialize("kind", kind); + manifest.serialize("chainId", block.chainid); + manifest = manifest.serialize("oracle", oracle); + manifest.write(outPath); + console2.log("Manifest written:", outPath); + } + + // ── env + // ─────────────────────────────────────────────────────────────────────── + + function _requireUint(string memory key) internal view returns (uint256) { + try vm.envUint(key) returns (uint256 v) { + return v; + } catch { + revert(string.concat("Missing env ", key)); + } + } + + function _isChainlink(string memory k) internal pure returns (bool) { + return keccak256(bytes(k)) == keccak256("chainlink"); + } + + function _isUniswap(string memory k) internal pure returns (bool) { + return keccak256(bytes(k)) == keccak256("uniswap"); + } +}