From 3e923ed23181697198915de659f824a1a48fc6d4 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Mon, 17 Aug 2026 18:04:28 +0300 Subject: [PATCH 01/12] Introduce the Sovryn Perimeter Delay core contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Sovryn security perimeter. Where the Perimeter Fee takes a cut of a user-initiated exit, the Perimeter Delay can hold the remainder for a governance-configured period, so a detected theft can be frozen or blacklisted and routed to recovery before the funds leave the protocol. - ExitDelayQueue: a per-request escrow holding the user leg of an exit until its unlock time. Requests are immutable once recorded, ingress is restricted to registered product sources, and the payout is settled before any external call. Only the originator or the position owner may execute a request — the receiver never can, so a withdrawal split into a fee leg and a delayed leg is always completed by the same actor who started it; - three ways out of the queue: normal execution after unlock, a pre-registered recovery route for funds belonging to a blacklisted party, and an owner-level catch-all bounded to requests that are blocked, paused or still locked, so a healthy in-flight exit can never be touched by governance; - self-service recovery for an undeliverable payout: the stored receiver is attempted first and an alternative is paid only if that genuinely bounces, which keeps a healthy exit from being redirected; - ExitFeeController gains the delay extension: a global kill switch independent of the fee switch, one global delay, and bypass tiers mirroring the fee tiers (actor, sub-product, surface) plus a surface-scoped passthrough registry for contracts that withdraw on a user's behalf; - deploy and verification scripts for the queue, its host wiring, and the go-live gates; unit, invariant and Echidna coverage for the queue. The delay ships disabled and is enabled only by governance after post-deployment verification. --- foundry.toml | 14 + script/05_DeployQueueAndWire.s.sol | 263 +++ script/06_VerifyActivation.s.sol | 438 ++++ script/InspectController.s.sol | 198 +- src/ExitDelayQueue.sol | 1078 +++++++++ src/ExitFeeController.sol | 614 ++++- src/interfaces/IExitDelayQueue.sol | 305 +++ src/interfaces/IExitDelayQueueHost.sol | 26 + src/interfaces/IExitFeeController.sol | 189 ++ test/echidna/EchidnaExitDelayQueue.sol | 205 ++ test/echidna/echidna.yaml | 10 + test/fixtures/BadV3.sol | 42 +- test/fixtures/GoodPackedV2.sol | 56 +- test/invariant/ExitDelayQueue.invariant.t.sol | 147 ++ test/invariant/ExitDelayQueueHandler.sol | 298 +++ test/unit/DeployQueueAndWire.t.sol | 317 +++ test/unit/ExitDelayQueue.t.sol | 2094 +++++++++++++++++ test/unit/ExitDelayQueueGrief.t.sol | 329 +++ test/unit/ExitDelayQueueUnwrapStipend.t.sol | 226 ++ test/unit/ExitFeeController.t.sol | 1016 +++++++- test/unit/InspectControllerDiscovery.t.sol | 121 + test/unit/VerifyActivation.t.sol | 714 ++++++ tools/check-abi-equivalence.sh | 66 +- 23 files changed, 8624 insertions(+), 142 deletions(-) create mode 100644 script/05_DeployQueueAndWire.s.sol create mode 100644 script/06_VerifyActivation.s.sol create mode 100644 src/ExitDelayQueue.sol create mode 100644 src/interfaces/IExitDelayQueue.sol create mode 100644 src/interfaces/IExitDelayQueueHost.sol create mode 100644 test/echidna/EchidnaExitDelayQueue.sol create mode 100644 test/echidna/echidna.yaml create mode 100644 test/invariant/ExitDelayQueue.invariant.t.sol create mode 100644 test/invariant/ExitDelayQueueHandler.sol create mode 100644 test/unit/DeployQueueAndWire.t.sol create mode 100644 test/unit/ExitDelayQueue.t.sol create mode 100644 test/unit/ExitDelayQueueGrief.t.sol create mode 100644 test/unit/ExitDelayQueueUnwrapStipend.t.sol create mode 100644 test/unit/InspectControllerDiscovery.t.sol create mode 100644 test/unit/VerifyActivation.t.sol diff --git a/foundry.toml b/foundry.toml index 80679fb..6ba579a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -29,6 +29,20 @@ fs_permissions = [ # the actual emitted warning code, and add it below. Do not guess the value. # ignored_error_codes = [] +[profile.default.invariant] +# Bounded so the stateful ExitDelayQueue suite (15 handler selectors, per-step +# index-consistency assertions that page the active sets) completes in CI time. +# Raise runs/depth for a deeper campaign; the defaults (256×500) are too heavy +# for the O(n) index paging on every step. +runs = 64 +depth = 60 +# the handlers try/catch every EXPECTED queue revert and the non-queue +# ops (token/native transfers off a huge pre-funded balance, force-sends, +# vm.warp) are revert-free by construction, so the suite is revert-free and we +# assert it. The prior `(aSeed+1)%3` receiver index was fixed to `(aSeed%3+1)%3` +# (no more 0x11 overflow-panic at a max seed) so nothing leaks past the catches. +fail_on_revert = true + [profile.default.fmt] line_length = 110 diff --git a/script/05_DeployQueueAndWire.s.sol b/script/05_DeployQueueAndWire.s.sol new file mode 100644 index 0000000..70f98d3 --- /dev/null +++ b/script/05_DeployQueueAndWire.s.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; + +/// @title Deploy ExitDelayQueue + wire the product hosts (activation step 1) +/// @notice activation ordering STEP 1: deploy the `ExitDelayQueue` impl + +/// ERC1967Proxy alongside the already-deployed `ExitFeeController` (from +/// `03_DeployController`), then WIRE the queue pointer into each product +/// host via `setExitDelayQueue`: the `sovrynProtocol` +/// singleton (lending + loan/margin) and the Zero `BorrowerOperations` +/// proxy. +/// +/// IMPORTANT — this script performs ONLY the deploy + host-wire (step 1). +/// It DELIBERATELY does NOT configure the security-critical controller +/// state (`setAdmin`, `setGlobalDelaySeconds`) — those are activation steps 4–5 +/// OWNER actions, executed as a SIP / Safe tx, NOT by this deployer script +/// (roles-not-actors: the deployer must not silently hold guardian/delay +/// authority). It also does NOT enable the perimeter (step 8). +/// +/// The go-live GATES — (`controller.admin == queue.admin`) +/// and (`controller.globalDelaySeconds >= queue.minimumDelaySeconds`) +/// — are NOT asserted here. They CANNOT be: this script runs at step 1, +/// BEFORE the Owner has configured the controller's admin + global delay +/// (steps 4–5), so `controller.admin() == 0` and `globalDelaySeconds() == 0` +/// at this point. A correctly-ordered first deploy would therefore ALWAYS +/// revert if the assertions lived here (the SP2-CTRL-02-ordering bug). +/// Instead they live in the dedicated READ-ONLY `06_VerifyActivation.s.sol` +/// verify script, run LAST as the step-7 go-live gate — AFTER the Owner +/// has configured admin + globalDelaySeconds. +/// +/// The controller never reads or calls the queue at RUNTIME (kill-switch +/// queue-independence). The host wiring here is the ONLY +/// on-chain coupling and it is one-directional (host → queue pointer). +/// +/// @dev Usage: +/// +/// export EXIT_DELAY_QUEUE_OWNER=0x... # queue Owner (governance Safe / timelock) +/// export EXIT_DELAY_QUEUE_ADMIN=0x... # queue Admin (must != Owner; == controller.admin, set later) +/// export WRBTC_ADDRESS=0x... # canonical wrapped-RBTC ERC20 +/// export QUEUE_MIN_DELAY_SECONDS=3600 # per-request delay floor +/// export QUEUE_ALLOWED_SOURCES=0xA,0xB # comma-separated hooked sources (optional; activation step 2) +/// # Product hosts to wire (activation step 1). BOTH are REQUIRED non-zero UNLESS you +/// # set DEFER_HOSTS=true. A missing/misspelled host env var must ABORT the deploy +/// # (C2, no silent address(0) no-op wire) — deferral must be EXPLICIT, never +/// # implicit-via-blank. When DEFER_HOSTS=true, an unset/zero host is skipped so +/// # its wiring can be moved to a later per-host Owner SIP/Safe tx. +/// export SOVRYN_PROTOCOL_HOST=0x... # sovrynProtocol singleton (lending + loan/margin) +/// export ZERO_BORROWER_OPERATIONS_HOST=0x... # Zero BorrowerOperations proxy +/// export DEFER_HOSTS=true # OPTIONAL explicit opt-in to defer a zero host +/// +/// forge script script/05_DeployQueueAndWire.s.sol \ +/// --rpc-url $RSK_RPC --broadcast --account deployer \ +/// --sig "run(uint256)" +/// +/// Reads `deployments//ExitFeeController.json` for the controller proxy. +/// After this script: +/// 1. tools/finalize-deployment.sh ExitDelayQueue 05_DeployQueueAndWire +/// 2. activation steps 2–6 (allowed sources, routes, setGlobalDelaySeconds, +/// setAdmin×2, bypass) — Owner SIP / Safe txs. +/// 3. `06_VerifyActivation.s.sol` — the step-7 read-only go-live gate. +/// 4. controller.setSecurityPerimeterEnabled(true) — step 8. +contract DeployQueueAndWire is Script { + using stdJson for string; + + /// @dev Validated deploy configuration, parsed from env by `_readConfig`. Held + /// in a struct so `run()` keeps only ONE live local across the broadcast + /// (avoids a stack-too-deep on the non-viaIR Paris build). + struct DeployConfig { + address queueOwner; + address queueAdmin; + address wrbtc; + uint32 minDelay; + address[] allowedSources; + address sovrynHost; + address zeroHost; + } + + /// @param chainId the target chain (selects the deployments artifact dir) + /// @return queueProxy the deployed ExitDelayQueue proxy + /// @return queueImpl the deployed ExitDelayQueue implementation + function run(uint256 chainId) external returns (address queueProxy, address queueImpl) { + ExitFeeController controller = _controller(chainId); + DeployConfig memory cfg = _readConfig(); + + console2.log("ExitFeeController @", address(controller)); + console2.log(" controller.admin (pre-config, expect 0):", controller.admin()); + console2.log( + " controller.globalDelaySeconds (pre-config, expect 0):", + uint256(controller.globalDelaySeconds()) + ); + console2.log(" queue owner: ", cfg.queueOwner); + console2.log(" queue admin: ", cfg.queueAdmin); + console2.log(" queue minimumDelaySeconds: ", uint256(cfg.minDelay)); + console2.log(" sovrynProtocol host: ", cfg.sovrynHost); + console2.log(" Zero BorrowerOperations host: ", cfg.zeroHost); + console2.log(""); + + uint256 wiredCount; + vm.startBroadcast(); + + // ── Deploy the queue behind its proxy (initialize sets owner/admin/floor). ── + queueImpl = address(new ExitDelayQueue()); + queueProxy = address( + new ERC1967Proxy( + queueImpl, + abi.encodeCall( + ExitDelayQueue.initialize, + (cfg.queueOwner, cfg.queueAdmin, cfg.wrbtc, cfg.minDelay, cfg.allowedSources) + ) + ) + ); + + // ── WIRE the queue pointer into each supplied product host. ── + // setExitDelayQueue is host-Owner/Timelock-gated; the broadcast wallet + // must hold that authority for the wire to land (else the host reverts). + // A zero host is skipped (only reachable under DEFER_HOSTS=true — the + // require()s in _readConfig already aborted a blank host otherwise). + wiredCount = wireHosts(queueProxy, cfg.sovrynHost, cfg.zeroHost); + + vm.stopBroadcast(); + + console2.log("ExitDelayQueue impl deployed at: ", queueImpl); + console2.log("ExitDelayQueue proxy deployed at: ", queueProxy); + console2.log(""); + // (C2) The "hosts wired" success line prints ONLY when a host was actually + // wired — a deferred-all deploy says so explicitly instead of claiming + // a wire that never happened. + if (wiredCount > 0) { + console2.log( + unicode"OK: queue deployed + hosts wired (activation step 1). hosts wired:", wiredCount + ); + } else { + console2.log( + unicode"OK: queue deployed, NO hosts wired (DEFER_HOSTS) — wire each host via a later Owner SIP (activation step 1)." + ); + } + console2.log(unicode"NOTE: admin + globalDelaySeconds are Owner steps 4-5 (NOT set here)."); + console2.log(unicode"Verify with 06_VerifyActivation.s.sol (step-7 gate) AFTER steps 4-5,"); + console2.log(unicode"then controller.setSecurityPerimeterEnabled(true) (step 8)."); + console2.log("Next: tools/finalize-deployment.sh ExitDelayQueue 05_DeployQueueAndWire "); + } + + /// @dev Locate the already-deployed controller from its deployment artifact. + function _controller(uint256 chainId) internal view returns (ExitFeeController) { + string memory ctrlArtifact = + vm.readFile(string.concat("deployments/", vm.toString(chainId), "/ExitFeeController.json")); + return ExitFeeController(ctrlArtifact.readAddress(".proxyAddress")); + } + + /// @dev Parse + VALIDATE the deploy config from env (C1/C2: no silent + /// address(0) defaults). A missing/misspelled critical env var ABORTS the + /// deploy here rather than defaulting to address(0) on-chain. In particular + /// EXIT_DELAY_QUEUE_OWNER is required (C1): ExitDelayQueue.initialize + /// resolves a zero `owner_` to msg.sender, which would silently leave the + /// deployer EOA holding UUPS/upgrade/source-registry/recovery/sweep + /// authority. Product hosts are REQUIRED non-zero unless DEFER_HOSTS=true + /// (C2: a blank host must ABORT, never no-op-wire a fail-open surface; + /// deferral must be EXPLICIT). + function _readConfig() internal view returns (DeployConfig memory cfg) { + cfg.queueOwner = vm.envAddress("EXIT_DELAY_QUEUE_OWNER"); + cfg.queueAdmin = vm.envAddress("EXIT_DELAY_QUEUE_ADMIN"); + cfg.wrbtc = vm.envAddress("WRBTC_ADDRESS"); + uint256 minDelay = vm.envUint("QUEUE_MIN_DELAY_SECONDS"); + require(minDelay <= type(uint32).max, "05: QUEUE_MIN_DELAY_SECONDS overflows uint32"); + cfg.minDelay = uint32(minDelay); + + cfg.allowedSources = vm.envOr("QUEUE_ALLOWED_SOURCES", ",", new address[](0)); + + cfg.sovrynHost = vm.envOr("SOVRYN_PROTOCOL_HOST", address(0)); + cfg.zeroHost = vm.envOr("ZERO_BORROWER_OPERATIONS_HOST", address(0)); + + // The address/host safety checks are factored into a PURE validator so a + // unit test can drive every abort branch on an in-memory struct — NO + // vm.setEnv (which mutates process-global env forge does not isolate + // between parallel test functions, a genuine race across env-driven tests). + validateConfig(cfg, vm.envOr("DEFER_HOSTS", false)); + } + + /// @notice Validate the deploy config (C1/C2 no-silent-blanks safety). Reverts + /// with a DISTINCT message per missing/misspelled critical input; + /// returns silently when the config is deployable. Pure so it is + /// driveable in a unit test with an in-memory struct, race-free. + /// @param cfg the parsed config + /// @param deferHosts the explicit DEFER_HOSTS opt-in (true ⇒ a zero host is + /// an intentional deferral, not an error) + function validateConfig(DeployConfig memory cfg, bool deferHosts) public pure { + // C1 — no silent address(0) for the security-critical queue inputs. A zero + // owner would let ExitDelayQueue.initialize resolve owner_ to msg.sender, + // silently leaving the deployer EOA with queue authority — so abort loud. + require( + cfg.queueOwner != address(0), + "05: EXIT_DELAY_QUEUE_OWNER must be set (C1: zero owner => deployer EOA holds queue authority)" + ); + require(cfg.queueAdmin != address(0), "05: EXIT_DELAY_QUEUE_ADMIN must be set"); + require( + cfg.queueAdmin != cfg.queueOwner, + "05: EXIT_DELAY_QUEUE_ADMIN must differ from EXIT_DELAY_QUEUE_OWNER (Admin != Owner)" + ); + require(cfg.wrbtc != address(0), "05: WRBTC_ADDRESS must be set"); + + // C2 — a blank host must ABORT (never no-op-wire a fail-open zero-delay + // surface) UNLESS deferral is EXPLICIT via DEFER_HOSTS=true. + if (!deferHosts) { + require( + cfg.sovrynHost != address(0), + "05: SOVRYN_PROTOCOL_HOST must be set (or set DEFER_HOSTS=true to defer)" + ); + require( + cfg.zeroHost != address(0), + "05: ZERO_BORROWER_OPERATIONS_HOST must be set (or set DEFER_HOSTS=true to defer)" + ); + } + + // (GATE4-02 / SP2-G5R2-01) A non-zero duplicate pair (both spec-named host + // vars pointing at the SAME address — a copy-paste footgun) would wire one + // surface twice and leave the OTHER silently unwired at zero-delay. Reject + // it. The `== address(0)` clause preserves the both-zero DEFER_HOSTS path. + require( + cfg.sovrynHost != cfg.zeroHost || cfg.sovrynHost == address(0), + "05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" + ); + } + + /// @notice Wire the queue pointer into the two product hosts, + /// skipping any zero host (wiring deferred to a per-host SIP/Safe tx). + /// Public so a unit test can drive the wire step directly WITHOUT the + /// env-parsing broadcast path (whose `vm.setEnv` mutates process-global + /// env that forge does not isolate between parallel test functions). + /// Must be called from a context authorized to set each host pointer. + /// @return wiredCount how many hosts were actually wired (a zero host is not + /// counted) — drives the conditional "hosts wired" success line (C2). + function wireHosts(address queue, address sovrynHost, address zeroHost) + public + returns (uint256 wiredCount) + { + if (_wireHost(sovrynHost, queue, "sovrynProtocol")) wiredCount++; + if (_wireHost(zeroHost, queue, "Zero BorrowerOperations")) wiredCount++; + } + + /// @dev Wire one product host's queue pointer, verifying the write took. + /// A zero host is a no-op (wiring deferred to a later per-host SIP/Safe tx). + /// @return wired true iff the host was non-zero and its pointer was written. + function _wireHost(address host, address queue, string memory label) internal returns (bool wired) { + if (host == address(0)) { + console2.log(string.concat(" wire SKIPPED (host unset): "), label); + return false; + } + IExitDelayQueueHost(host).setExitDelayQueue(queue); + require( + IExitDelayQueueHost(host).exitDelayQueue() == queue, "05: setExitDelayQueue did not take on host" + ); + console2.log(string.concat(" wired queue -> host: "), label); + return true; + } +} diff --git a/script/06_VerifyActivation.s.sol b/script/06_VerifyActivation.s.sol new file mode 100644 index 0000000..63bd12d --- /dev/null +++ b/script/06_VerifyActivation.s.sol @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {stdJson} from "forge-std/StdJson.sol"; + +import {ExitFeeController} from "../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; + +/// @title Verify Activation — activation step-7 go-live gate (C1/C2) +/// @notice READ-ONLY. Run LAST, AFTER the Owner has configured the controller's +/// `admin` + `globalDelaySeconds` (activation steps 4–5), the guardian on both +/// contracts (step 5), the allowed-sources + host wiring (steps 1–2), and +/// BEFORE `setSecurityPerimeterEnabled(true)` (step 8): a fat-fingered +/// security-contract deploy must fail LOUD here rather than silently ship +/// fail-open (unwired host = zero-delay direct-pay) or fail-closed +/// (mis-owned / wired-but-not-allowed = bricked). +/// +/// SCOPE (honest): this gate verifies the two +/// STORAGE HOSTS are wired + allowed-source, plus guardian/floor/ownership. +/// It does NOT enumerate the actual record CALLERS (iToken proxies for +/// lender burn/burnToBTC; the ActivePool native pusher) — those live in the +/// separate step-2 source SIP (QUEUE_ALLOWED_SOURCES + setNativePusher) and +/// are confirmed there. A missing record-caller source is fail-CLOSED +/// (records revert UnregisteredSource, Owner-remediable via addAllowedSource), +/// so it is out of this gate's scope by design, not an unguarded fail-open. +/// +/// Asserts, each with a DISTINCT revert message: +/// controller.admin() == queue.admin(), both non-zero -- guardian +/// controller.globalDelaySeconds() > 0 AND +/// >= queue.minimumDelaySeconds() -- delay floor +/// (C2: the `> 0` gates the ACTIVE global delay — a +/// zero global delay leaves the perimeter inert. The queue's +/// `minimumDelaySeconds` FLOOR itself MAY be 0; that only leaves +/// the per-request DelayBelowFloor backstop inactive — Owner- +/// remediable, NOT a go-live blocker.) +/// (C1) queue.owner() == governanceOwner != deployer +/// controller.owner() == governanceOwner != deployer -- ownership +/// (C2) for each intended host: +/// host.exitDelayQueue() == queue -- wired +/// queue.isAllowedSource(host) -- allowed-source +/// +/// C1 host-input safety: both spec-named product hosts are +/// REQUIRED inputs read via `vm.envAddress` (which REVERTS on an unset or +/// typo'd var) — a fresh-shell / mistyped run must NOT silently yield an +/// empty intended-host list and vacuously "PASS" while a surface ships +/// unwired at zero-delay (fail-open). Skipping a host is allowed ONLY via an +/// EXPLICIT, LOUD `VERIFY_DEFER_HOSTS=true` opt-in; each deferred host prints +/// a warning line and the success banner is downgraded to the qualified +/// (warned) variant. The unqualified "safe to run step 8" banner is emitted +/// ONLY when BOTH spec-named hosts were actually checked. +/// +/// The assertions live HERE — NOT inside `05_DeployQueueAndWire`'s broadcast +/// (step 1, BEFORE config) — so the documented activation order does not +/// self-abort (the SP2-CTRL-02-ordering fix). +/// +/// Read-only: no `vm.startBroadcast()`, no state change. verify() does NOT +/// mutate (roles-not-actors: setAdmin/setGlobalDelaySeconds stay Owner +/// actions run at steps 4–5, not here). +/// +/// @dev Usage: +/// +/// export EXIT_DELAY_GOVERNANCE_OWNER=0x... # intended Owner (governance Safe/timelock) +/// export EXIT_DELAY_DEPLOYER=0x... # the broadcast EOA that ran the deploy +/// export SOVRYN_PROTOCOL_HOST=0x... # intended host (REQUIRED unless VERIFY_DEFER_HOSTS=true) +/// export ZERO_BORROWER_OPERATIONS_HOST=0x... # intended host (REQUIRED unless VERIFY_DEFER_HOSTS=true) +/// # export VERIFY_DEFER_HOSTS=true # ONLY to intentionally defer a host's wiring to a later SIP +/// +/// forge script script/06_VerifyActivation.s.sol \ +/// --rpc-url $RSK_RPC --sig "run(uint256)" +/// +/// Reads BOTH `deployments//ExitFeeController.json` and +/// `deployments//ExitDelayQueue.json` for the two proxy addresses, and +/// the intended-host list from the same env vars the deploy script wires from. +contract VerifyActivation is Script { + using stdJson for string; + + /// @param chainId the target chain (selects the deployments artifact dir) + function run(uint256 chainId) external view { + (ExitFeeController controller, ExitDelayQueue queue) = _loadProxies(chainId); + + // ── Intended governance Owner + deployer EOA (C1). Both REQUIRED non-zero + // so a blank never trivially satisfies "owner == governanceOwner" or the + // "owner != deployer" check. ── + address governanceOwner = vm.envAddress("EXIT_DELAY_GOVERNANCE_OWNER"); + address deployer = vm.envAddress("EXIT_DELAY_DEPLOYER"); + + // ── Intended product hosts (C1 host-input safety). BOTH spec- + // named hosts are REQUIRED via vm.envAddress (reverts on unset/typo) so a + // fresh-shell run can NEVER produce an empty list that vacuously PASSes + // while a surface ships unwired (fail-open zero-delay). A host may be + // skipped ONLY via an EXPLICIT `VERIFY_DEFER_HOSTS=true`. ── + bool deferHosts = vm.envOr("VERIFY_DEFER_HOSTS", false); + (address sovrynHost, address zeroHost) = _readHostEnv(deferHosts); + address[] memory hosts = _resolveHosts(deferHosts, sovrynHost, zeroHost); + + console2.log("ExitFeeController @", address(controller)); + console2.log("ExitDelayQueue @", address(queue)); + console2.log("governance owner @", governanceOwner); + console2.log("deployer EOA @", deployer); + console2.log("intended hosts :", hosts.length); + console2.log(""); + + verify(controller, queue, governanceOwner, deployer, hosts, deferHosts); + + // (G5R2-02) The banner is keyed off the RESOLVED FACT — whether BOTH + // spec-named hosts were actually C2-checked — NOT the raw VERIFY_DEFER_HOSTS + // flag: with defer=true but both hosts present, all surfaces ARE certified + // and the unqualified banner is correct; the qualified/warned banner is for + // an ACTUAL deferral (a spec-named host dropped from the checked list). + bool anyHostDeferred = hosts.length < _EXPECTED_HOST_COUNT; + _reportPass(anyHostDeferred); + } + + /// @dev The number of spec-named product hosts (activation step-7 scope: the + /// `sovrynProtocol` singleton + the Zero `BorrowerOperations` proxy). A + /// resolved list shorter than this means at least one host was deferred. + uint256 private constant _EXPECTED_HOST_COUNT = 2; + + /// @dev Read the two spec-named host env vars. When NOT deferring, use + /// `vm.envAddress` (REQUIRED — reverts LOUD on an unset or typo'd var, + /// exactly like 05_DeployQueueAndWire's non-zero rule) so a fresh-shell or + /// mistyped run cannot silently yield an empty intended-host list and + /// vacuously "PASS". When deferring, fall back to `vm.envOr(..., 0)` so an + /// operator can leave a host unset ON PURPOSE. + function _readHostEnv(bool deferHosts) internal view returns (address sovrynHost, address zeroHost) { + if (deferHosts) { + sovrynHost = vm.envOr("SOVRYN_PROTOCOL_HOST", address(0)); + zeroHost = vm.envOr("ZERO_BORROWER_OPERATIONS_HOST", address(0)); + } else { + // REQUIRED: reverts on unset/typo — no silent empty list. + sovrynHost = vm.envAddress("SOVRYN_PROTOCOL_HOST"); + zeroHost = vm.envAddress("ZERO_BORROWER_OPERATIONS_HOST"); + } + } + + /// @dev Read the controller + queue proxy addresses from the deployment + /// artifacts. Split out of `run` to keep its live-local count low + /// (avoids a stack-too-deep on the non-viaIR Paris build). + function _loadProxies(uint256 chainId) + internal + view + returns (ExitFeeController controller, ExitDelayQueue queue) + { + string memory dir = string.concat("deployments/", vm.toString(chainId), "/"); + controller = ExitFeeController( + vm.readFile(string.concat(dir, "ExitFeeController.json")).readAddress(".proxyAddress") + ); + queue = ExitDelayQueue( + payable(vm.readFile(string.concat(dir, "ExitDelayQueue.json")).readAddress(".proxyAddress")) + ); + } + + /// @notice Build the intended-host list from the two spec-named hosts, enforcing + /// the C1 host-input safety rule and emitting a LOUD per-host warning for + /// each explicitly-deferred (zero) host. Public + pure-of-env so a unit + /// test drives every branch on in-memory addresses WITHOUT `vm.setEnv` + /// (which mutates process-global env forge does not isolate between + /// parallel test functions — a genuine race). + /// + /// Contract: + /// - deferHosts == false: BOTH hosts MUST be non-zero (the caller's + /// `vm.envAddress` already reverts on unset; this is the belt-and- + /// suspenders check for a caller-supplied zero). A zero host here is + /// a hard REVERT — a surface would ship unwired at zero-delay. + /// - deferHosts == true: a zero host is an INTENTIONAL deferral; it is + /// dropped from the list and a LOUD warning is printed naming the + /// surface whose delay is NOT yet active. + /// + /// @param deferHosts the explicit VERIFY_DEFER_HOSTS opt-in + /// @param sovrynHost SOVRYN_PROTOCOL_HOST (lending + loan/margin surfaces) + /// @param zeroHost ZERO_BORROWER_OPERATIONS_HOST (Zero surface) + /// @return hosts the tightly-sized list of hosts that WILL be C2-checked + function _resolveHosts(bool deferHosts, address sovrynHost, address zeroHost) + internal + view + returns (address[] memory hosts) + { + // (GATE4-02 / SP2-G5R2-01) A non-zero duplicate pair (both spec-named vars + // pointing at the SAME host — a copy-paste footgun) would C2-check one + // surface twice and leave the OTHER silently unchecked/unwired. Reject it. + // The `== address(0)` clause preserves the both-zero EXPLICIT-defer path + // (deferHosts==true, both hosts intentionally unset): that is not a dup. + require( + sovrynHost != zeroHost || sovrynHost == address(0), + "SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" + ); + + bool sovrynIn = _includeHost(deferHosts, sovrynHost, "sovrynProtocol (SOVRYN_PROTOCOL_HOST)"); + bool zeroIn = + _includeHost(deferHosts, zeroHost, "Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST)"); + + uint256 n; + if (sovrynIn) n++; + if (zeroIn) n++; + + hosts = new address[](n); + uint256 i; + if (sovrynIn) hosts[i++] = sovrynHost; + if (zeroIn) hosts[i++] = zeroHost; + } + + /// @dev Decide whether one spec-named host is included in the C2-checked list. + /// A non-zero host is always included. A zero host is a hard REVERT unless + /// deferral is EXPLICIT (VERIFY_DEFER_HOSTS=true), in which case it is + /// dropped with a LOUD warning naming the un-delayed surface. + function _includeHost(bool deferHosts, address host, string memory label) + internal + view + returns (bool included) + { + if (host != address(0)) return true; + // host == 0 + require( + deferHosts, + string.concat( + "SP2-CTRL-02 (C1): intended host ", + label, + " == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + console2.log(unicode"⚠ perimeter enabling with host UNWIRED -- that surface has NO delay:"); + console2.log(string.concat(unicode" ", label)); + return false; + } + + /// @dev Emit the go-live success banner. The UNQUALIFIED "safe to run step 8" + /// banner is emitted ONLY when BOTH spec-named hosts were actually checked + /// (`anyHostDeferred == false`). When at least one spec-named host was + /// ACTUALLY dropped from the checked list (a real deferral) the banner is + /// DOWNGRADED to the qualified/warned variant so a deferred surface is never + /// implicitly certified. (G5R2-02: keyed off the resolved fact, NOT the raw + /// VERIFY_DEFER_HOSTS flag — defer=true with both hosts present certifies + /// everything and earns the unqualified banner.) + /// @param anyHostDeferred true iff fewer than the two spec-named hosts were + /// actually C2-checked (a real deferral happened) + function _reportPass(bool anyHostDeferred) internal view { + if (anyHostDeferred) { + console2.log(unicode"OK (QUALIFIED): activation step-7 gate PASSED for the CHECKED hosts."); + console2.log(unicode" guardian + delay floor + ownership + wiring for wired hosts."); + console2.log( + unicode"⚠ VERIFY_DEFER_HOSTS=true -- one or more surfaces are UNWIRED (see warnings above)." + ); + console2.log( + unicode" Do NOT treat this as a full go-live: each deferred host has NO delay until wired + re-verified." + ); + } else { + console2.log( + unicode"OK: activation step-7 gate PASSED for: guardian + delay floor + ownership + host wiring." + ); + console2.log( + unicode"NOTE (scope): this gate verifies the two STORAGE HOSTS are wired + allowed-source. It does" + ); + console2.log( + unicode" NOT enumerate the actual record CALLERS (iToken proxies for lender burn/burnToBTC; ActivePool" + ); + console2.log( + unicode" native pusher) — those are registered/verified by the step-2 source SIP (QUEUE_ALLOWED_SOURCES" + ); + console2.log( + unicode" + setNativePusher). A missing one is fail-CLOSED (records revert UnregisteredSource, Owner-remediable)." + ); + console2.log( + unicode"Safe to run step 8 (setSecurityPerimeterEnabled(true)) ONCE the step-2 source set is confirmed." + ); + } + } + + /// @notice The step-7 COMPREHENSIVE go-live gate as a public read-only entry, so + /// a unit test — and an operator running a dry-run against live proxies — + /// can drive the EXACT assertions the `run` path runs. Reverts on any + /// unmet invariant with a DISTINCT message; returns silently when the + /// whole deploy is correctly configured. Read-only (`view`), never + /// mutates (setAdmin/setGlobalDelaySeconds are Owner steps 4–5, not this). + /// + /// Revert taxonomy (distinct messages; "not yet configured" variants + /// first within each check so an early run points the operator back at + /// the missing step rather than at a false mis-config): + /// - admin unset -> "...(unconfigured): controller.admin==0..." + /// - admin mismatch -> "...: controller.admin() != queue.admin()..." + /// - globalDelaySeconds==0 -> "...(unconfigured): controller.globalDelaySeconds==0..." + /// - sub-floor delay -> "...: controller.globalDelaySeconds() < ...floor..." + /// - owner still deployer -> "...(C1): queue/controller.owner() == deployer..." + /// - owner != governance -> "...(C1): queue/controller.owner() != governance owner..." + /// - host not wired -> "...(C2): host not wired..." + /// - host not allowed-src -> "...(C2): host not allowed-source..." + /// + /// @param controller the deployed ExitFeeController proxy + /// @param queue the deployed ExitDelayQueue proxy + /// @param governanceOwner the intended governance Owner (must own BOTH; != deployer) + /// @param deployer the broadcast EOA that ran the deploy (must own NEITHER) + /// @param hosts the intended product hosts (each must be wired + allowed-source) + /// @param deferHosts the explicit VERIFY_DEFER_HOSTS opt-in. An EMPTY host + /// list is a vacuous wiring PASS (fail-open: a surface + /// could ship unwired at zero-delay while this gate + /// certifies "safe to run step 8"). The dry-run entry + /// REFUSES an empty list unless the caller EXPLICITLY + /// asserts deferral via `deferHosts == true` (C1, + /// "refuse vacuous PASS"). The + /// legitimate defer-both `run()` path still passes. + function verify( + ExitFeeController controller, + ExitDelayQueue queue, + address governanceOwner, + address deployer, + address[] memory hosts, + bool deferHosts + ) public view { + // (C1) Refuse a vacuous wiring PASS: an empty intended-host list means the + // C2 wiring loop asserts NOTHING, so the gate would certify go-live while a + // surface ships unwired (fail-open zero-delay). Only an EXPLICIT deferral + // opt-in may legitimately produce an empty list. + require( + hosts.length != 0 || deferHosts, + "SP2-CTRL-02 (C1): empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + ); + _verifyGuardian(controller, queue); // + _verifyFloor(controller, queue); + // + _verifyOwnership(controller, queue, governanceOwner, deployer); + // C1 + _verifyWiring(queue, hosts); // C2 + } + + // ── single guardian. "not yet configured" (admin==0) is distinct from a + // genuine guardian mismatch. ── + function _verifyGuardian(ExitFeeController controller, ExitDelayQueue queue) internal view { + address ctrlAdmin = controller.admin(); + address queueAdmin = queue.admin(); + require( + ctrlAdmin != address(0), + "guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first" + ); + require( + ctrlAdmin == queueAdmin, + "single guardian violated: controller.admin() != queue.admin() -- single guardian violated" + ); + } + + // ── delay-floor liveness. globalDelaySeconds==0 (step 4 not run) is + // distinct from a configured-but-sub-floor delay. The queue's own per-request + // `require(d >= minimumDelaySeconds)` is the SAFETY enforcement; this is the + // LIVENESS check that a legit global delay does not sit below the floor and + // self-brick every non-bypassed exit. + // + // C2: the `> 0` guard is on the ACTIVE globalDelaySeconds — a + // zero global delay = inert perimeter, rejected here at go-live. The queue's + // `minimumDelaySeconds` FLOOR itself MAY be 0 (the per-request DelayBelowFloor + // backstop is then inactive, Owner-remediable, NOT a go-live blocker) — so + // there is deliberately NO `minimumDelaySeconds != 0` assertion. ── + function _verifyFloor(ExitFeeController controller, ExitDelayQueue queue) internal view { + uint256 globalDelay = uint256(controller.globalDelaySeconds()); + uint256 floor = uint256(queue.minimumDelaySeconds()); + require( + globalDelay != 0, + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ); + require( + globalDelay >= floor, + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ); + } + + // ── C1: ownership. BOTH the queue and the controller must be owned by the + // intended governance Owner and NOT by the deployer EOA — a blank/zero owner + // that silently left the deployer in control (ExitDelayQueue.initialize + // resolves a zero owner_ to msg.sender) MUST NOT pass go-live. The + // "owner == deployer" check is reported BEFORE the "== governanceOwner" + // check so the still-deployer case gets the most actionable message. ── + function _verifyOwnership( + ExitFeeController controller, + ExitDelayQueue queue, + address governanceOwner, + address deployer + ) internal view { + require( + governanceOwner != address(0), + "SP2-CTRL-02 (C1 unconfigured): governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + ); + require( + deployer != address(0), + "SP2-CTRL-02 (C1 unconfigured): deployer arg == 0 -- set EXIT_DELAY_DEPLOYER" + ); + require( + governanceOwner != deployer, + "SP2-CTRL-02 (C1 unconfigured): governance owner == deployer -- they must differ" + ); + + address queueOwner = queue.owner(); + address ctrlOwner = controller.owner(); + + // still-deployer first (the silent-blank-owner footgun this gate exists for) + require( + queueOwner != deployer, + "SP2-CTRL-02 (C1): queue.owner() == deployer EOA -- ownership not handed to governance" + ); + require( + ctrlOwner != deployer, + "SP2-CTRL-02 (C1): controller.owner() == deployer EOA -- ownership not handed to governance" + ); + require(queueOwner == governanceOwner, "SP2-CTRL-02 (C1): queue.owner() != governance owner"); + require(ctrlOwner == governanceOwner, "SP2-CTRL-02 (C1): controller.owner() != governance owner"); + } + + // ── C2: wiring. Every intended host must (i) point its queue pointer at THIS + // queue (else that surface pays direct at zero-delay = fail-OPEN) AND (ii) be + // a registered allowed-source (else its records revert UnregisteredSource = + // bricked fail-CLOSED). Both mis-states must be caught before enabling. The + // revert names the specific host address for a fast fix. ── + function _verifyWiring(ExitDelayQueue queue, address[] memory hosts) internal view { + for (uint256 i; i < hosts.length; i++) { + address host = hosts[i]; + // (defensive: a zero host is never an intended host — _resolveHosts + // filters/reverts them — but guard so a caller-supplied list cannot + // slip a 0.) + require(host != address(0), "SP2-CTRL-02 (C2): intended host == 0"); + + require( + IExitDelayQueueHost(host).exitDelayQueue() == address(queue), + string.concat( + "SP2-CTRL-02 (C2): host ", + vm.toString(host), + " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" + ) + ); + require( + queue.isAllowedSource(host), + string.concat( + "SP2-CTRL-02 (C2): host ", + vm.toString(host), + " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" + ) + ); + } + } +} diff --git a/script/InspectController.s.sol b/script/InspectController.s.sol index 40a36ef..250323a 100644 --- a/script/InspectController.s.sol +++ b/script/InspectController.s.sol @@ -7,6 +7,7 @@ import {stdJson} from "forge-std/StdJson.sol"; import {ExitFeeController} from "../src/ExitFeeController.sol"; import {IExitFeeController} from "../src/interfaces/IExitFeeController.sol"; +import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; /// @title Inspect ExitFeeController /// @notice Prints the live state of a deployed `ExitFeeController` proxy: @@ -27,7 +28,7 @@ contract InspectController is Script { // Every canonical surface, including the ones that ship off -- an // inspector that skipped them would report "off" as silence. Keep - // aligned with docs/SURFACE_REGISTRY.md and the set that + // aligned with the registered surface names and the set that // 04_BootstrapController.s.sol writes. string[5] internal surfaceNames = [ "SURFACE_LENDING_LENDER_WITHDRAW", @@ -72,10 +73,184 @@ contract InspectController is Script { console2.log(" MAX_BPS: ", uint256(c.MAX_BPS())); console2.log(""); - console2.log(unicode"── Surface policies ──────────────────────────────────"); + console2.log(unicode"── Delay perimeter (global) ──────────────────────────"); + console2.log(" securityPerimeterEnabled:", c.securityPerimeterEnabled()); + console2.log(" globalDelaySeconds: ", uint256(c.globalDelaySeconds())); + console2.log(" admin: ", c.admin()); + console2.log(""); + + // ── single-guardian identity assertion. The controller keeps its + // OWN local `admin` (used ONLY by the kill switch) and NEVER reads + // queue.admin() at runtime (that would couple the kill switch to queue + // liveness and break). Instead this tooling asserts, at + // deploy/inspect time, that the two guardians are ONE identity so a + // single Safe bundle rotates both. Off unless the queue artifact exists. + _assertSingleGuardian(c, chainId); + + console2.log(unicode"── Surface policies + delay bypass ───────────────────"); for (uint256 i = 0; i < surfaceNames.length; i++) { _printSurface(c, surfaceNames[i]); } + + // ── dump EVERY delay-bypass + passthrough entry via the + // on-chain enumeration getters — NOT the hardcoded surface list above. + // A bypass / passthrough under an arbitrary surfaceId (never registered + // as a named fee surface) is still fully surfaced here. + _printDelayBypassRegistry(c); + _printPassthroughRegistry(c); + } + + /// @dev single-guardian assertion. Reads the queue proxy from its + /// deployment artifact (if present) and requires `controller.admin == + /// queue.admin`. This is a DEPLOY/INSPECT-time identity check ONLY — the + /// controller never reads the queue at runtime (kill-switch + /// queue-independence). Skipped with a notice when the queue + /// artifact is absent (e.g. controller-only inspection). + function _assertSingleGuardian(ExitFeeController c, uint256 chainId) internal view { + string memory path = string.concat("deployments/", vm.toString(chainId), "/ExitDelayQueue.json"); + try vm.readFile(path) returns (string memory queueArtifact) { + address queueProxy = queueArtifact.readAddress(".proxyAddress"); + address queueAdmin = ExitDelayQueue(payable(queueProxy)).admin(); + address ctrlAdmin = c.admin(); + console2.log(unicode"── Single-guardian identity ──────────────────"); + console2.log(" controller.admin:", ctrlAdmin); + console2.log(" queue.admin: ", queueAdmin); + require(ctrlAdmin == queueAdmin, "controller.admin != queue.admin (single guardian violated)"); + console2.log(" OK: single guardian identity holds"); + console2.log(""); + } catch { + console2.log(unicode"── Single-guardian identity ──────────────────"); + console2.log(" queue artifact absent -- skipping controller.admin==queue.admin check"); + console2.log(""); + } + } + + /// @dev the complete surfaceId probe set that drives BOTH the + /// bypass and passthrough dumps — + /// `bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` ∪ the named surfaces. + /// The `bypassSurfaceIds()` / `passthroughSurfaceIds()` master sets are + /// ANY-TIER-TOUCHED: a surface carrying ONLY a sub-product- or actor-tier + /// bypass (or ONLY a passthrough entry) is present here even though it was + /// never passed to `setSurfaceBypass`. The named fee surfaces are folded in + /// so the human-readable rows always render, and duplicates are collapsed. + /// Root cause of the old gap (`surfaceBypassKeys()`-only driver): the master + /// id-set was populated solely by `setSurfaceBypass`, so an actor-only bypass + /// or a passthrough-only entry under an arbitrary surfaceId was undiscoverable. + function _probeSurfaceIds(ExitFeeController c) internal view returns (bytes32[] memory) { + bytes32[] memory bypassIds = c.bypassSurfaceIds(); + bytes32[] memory passIds = c.passthroughSurfaceIds(); + + // Upper bound on the union size; trim to the deduped count below. + bytes32[] memory acc = new bytes32[](bypassIds.length + passIds.length + surfaceNames.length); + uint256 n = 0; + + // Named fee surfaces first (stable ordering; keeps human rows at the top). + for (uint256 i = 0; i < surfaceNames.length; i++) { + n = _pushUnique(acc, n, keccak256(abi.encodePacked("COLFEE:", surfaceNames[i]))); + } + for (uint256 i = 0; i < bypassIds.length; i++) { + n = _pushUnique(acc, n, bypassIds[i]); + } + for (uint256 i = 0; i < passIds.length; i++) { + n = _pushUnique(acc, n, passIds[i]); + } + + bytes32[] memory out = new bytes32[](n); + for (uint256 i = 0; i < n; i++) { + out[i] = acc[i]; + } + return out; + } + + /// @dev Append `id` to `acc[0..n)` iff not already present; returns the new n. + function _pushUnique(bytes32[] memory acc, uint256 n, bytes32 id) internal pure returns (uint256) { + for (uint256 i = 0; i < n; i++) { + if (acc[i] == id) return n; + } + acc[n] = id; + return n + 1; + } + + /// @dev Human-readable label for a surfaceId: the canonical name if it is a + /// named fee surface, else the raw bytes32. + function _labelFor(bytes32 id) internal view returns (string memory) { + for (uint256 i = 0; i < surfaceNames.length; i++) { + if (id == keccak256(abi.encodePacked("COLFEE:", surfaceNames[i]))) { + return surfaceNames[i]; + } + } + return vm.toString(id); + } + + /// @dev dump every surface / sub-product / actor delay-bypass + /// entry, driven by the ANY-TIER-TOUCHED probe set (`bypassSurfaceIds()` ∪ + /// `passthroughSurfaceIds()` ∪ named surfaces) so a sub-product- or + /// actor-only bypass under an arbitrary surfaceId is never missed. A probed + /// surfaceId with no live bypass entry at any tier prints nothing. + function _printDelayBypassRegistry(ExitFeeController c) internal view { + console2.log(unicode"── Delay-bypass registry (enumerated) ────────────────"); + bytes32[] memory ids = _probeSurfaceIds(c); + bool anyPrinted = false; + for (uint256 i = 0; i < ids.length; i++) { + bytes32 id = ids[i]; + + IExitFeeController.DelayBypassPolicy memory sb = c.surfaceBypass(id); + address[] memory subBp = c.subProductBypassKeys(id); + address[] memory actorBp = c.actorBypassKeys(id); + + // Skip a probed id that carries no bypass entry at any tier (e.g. a + // named fee surface or a passthrough-only surface with no bypass). + if (!sb.active && !sb.bypass && subBp.length == 0 && actorBp.length == 0) { + continue; + } + anyPrinted = true; + + console2.log(string.concat(" surface ", _labelFor(id), " ", _fmtBypass(sb))); + for (uint256 j = 0; j < subBp.length; j++) { + IExitFeeController.DelayBypassPolicy memory p = c.subProductBypass(id, subBp[j]); + console2.log(string.concat(" sub-product ", vm.toString(subBp[j]), " ", _fmtBypass(p))); + } + for (uint256 j = 0; j < actorBp.length; j++) { + IExitFeeController.DelayBypassPolicy memory p = c.actorBypass(id, actorBp[j]); + console2.log(string.concat(" actor ", vm.toString(actorBp[j]), " ", _fmtBypass(p))); + } + } + if (!anyPrinted) { + console2.log(" (no delay bypasses configured at any tier)"); + } + console2.log(""); + } + + /// @dev dump the surface-scoped passthrough registry via + /// `passthroughKeys(surfaceId)`, driven by the SAME any-tier-touched probe + /// set as the bypass dump (`bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` + /// ∪ named surfaces). A passthrough-only surface (no bypass entry, not a + /// named fee surface) is discoverable via `passthroughSurfaceIds()`. + function _printPassthroughRegistry(ExitFeeController c) internal view { + console2.log(unicode"── Passthrough registry (enumerated) ─────────────────"); + bytes32[] memory ids = _probeSurfaceIds(c); + bool anyPrinted = false; + for (uint256 i = 0; i < ids.length; i++) { + anyPrinted = _printPassthroughFor(c, ids[i], _labelFor(ids[i])) || anyPrinted; + } + if (!anyPrinted) { + console2.log(" (no passthrough actors configured)"); + } + console2.log(""); + } + + function _printPassthroughFor(ExitFeeController c, bytes32 id, string memory label) + internal + view + returns (bool) + { + address[] memory pks = c.passthroughKeys(id); + if (pks.length == 0) return false; + console2.log(string.concat(" ", label, ":")); + for (uint256 j = 0; j < pks.length; j++) { + console2.log(string.concat(" ", vm.toString(pks[j]))); + } + return true; } function _printSurface(ExitFeeController c, string memory name) internal view { @@ -104,6 +279,15 @@ contract InspectController is Script { console2.log(string.concat(" ", vm.toString(actors[j]), " ", _fmtPolicy(p))); } } + // NOTE: delay-bypass + passthrough tiers are dumped separately in + // `_printDelayBypassRegistry` / `_printPassthroughRegistry`, driven by the + // ANY-TIER-TOUCHED master sets `bypassSurfaceIds()` ∪ `passthroughSurfaceIds()` + // ∪ the named surfaces (NOT this hardcoded surface list, and NOT the + // surface-tier-only `surfaceBypassKeys()`). Because those + // master sets are recorded by EVERY bypass writer (surface / sub-product / + // actor) and by passthrough registration, an entry under an arbitrary + // surfaceId — including a sub-product- or actor-ONLY bypass or a + // passthrough-only entry — is discovered there. console2.log(""); } @@ -112,4 +296,14 @@ contract InspectController is Script { "(active=", p.active ? "true" : "false", ", rateBps=", vm.toString(uint256(p.rateBps)), ")" ); } + + function _fmtBypass(IExitFeeController.DelayBypassPolicy memory p) + internal + pure + returns (string memory) + { + return string.concat( + "(active=", p.active ? "true" : "false", ", bypass=", p.bypass ? "true" : "false", ")" + ); + } } diff --git a/src/ExitDelayQueue.sol b/src/ExitDelayQueue.sol new file mode 100644 index 0000000..3f7ec3b --- /dev/null +++ b/src/ExitDelayQueue.sol @@ -0,0 +1,1078 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from + "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import {IExitDelayQueue} from "./interfaces/IExitDelayQueue.sol"; + +/// @notice Minimal WRBTC (wrapped-RBTC) surface. `unwrapOnDelivery` requests +/// hold WRBTC and unwrap to native RBTC at `executeExit` (Option B). +interface IWRBTC { + function withdraw(uint256 amount) external; +} + +/// @title ExitDelayQueue +/// @notice Per-request escrow for the *user* leg of an exit. Each exit becomes +/// an immutable request with its own monotonic id; execution targets +/// explicit ids (no cursor, no ordering assumption). During the delay, +/// a detected theft can be Frozen/Blacklisted and disposed via the +/// three recovery legs. UUPS-upgradeable, mirroring the +/// `ExitFeeVault` skeleton. +/// +/// Authoritative build spec: +/// The invariants are enumerated with the properties below and +/// pinned by the forge invariant suite. +/// +/// Two-principal authority: `Owner` (Ownable2Step) holds UUPS +/// upgrade + all security-critical CONFIG; `Admin` (a single stored +/// address, `onlyAdminOrOwner`) is the fast guardian — freeze/blacklist, +/// pause, Leg-1 release, Leg-2 along Owner-approved routes. `Admin ≠ +/// Owner` is the one hard separation. +// +// The queue custodies escrowed RBTC/ERC20/WRBTC by design; the only value-in +// path is the gated ingress (record*/receive()), and value-out is CEI-ordered +// behind nonReentrant. aderyn's "contract-locks-ether" is intentional here. +// aderyn-ignore-next-line(contract-locks-ether) +contract ExitDelayQueue is + IExitDelayQueue, + Initializable, + UUPSUpgradeable, + Ownable2StepUpgradeable, + ReentrancyGuardUpgradeable +{ + using SafeERC20 for IERC20; + using EnumerableSet for EnumerableSet.UintSet; + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + + // ─── Storage layout ────────────────────────────────────────── + // Verified via `forge inspect ExitDelayQueue storageLayout`. Inherited-OZ + // namespaces occupy proxy slots 0..300 (Initializable/Context/Ownable/ + // Ownable2Step/ReentrancyGuard, each with a 50-slot __gap; UUPS/Context add + // no fields). This contract's own slot 0 sits at proxy slot 301: + // + // 301 admin (address; the Admin guardian — 1 slot, no OZ AccessControl) + // 302 lastRequestId (uint256) + // 303 _requests (mapping head) + // 304 _activeByParty (mapping head) + // 305 _recoveryRoutes (mapping head) + // 306 _recoveryRouteIds._values (Bytes32Set array head) ┐ 2 slots + // 307 _recoveryRouteIds._indexes (mapping head) ┘ + // 308 _topUpFeasible (mapping head) + // 309 _blockState (mapping head) + // 310 _blockedAccounts._values (AddressSet array head) ┐ 2 slots + // 311 _blockedAccounts._indexes (mapping head) ┘ + // 312 _blockTrigger (mapping head) + // 313 _allowedSource (mapping head) + // 314 _allowedSources._values (AddressSet array head) ┐ 2 slots + // 315 _allowedSources._indexes (mapping head) ┘ + // 316 _totalEscrowed (mapping head) + // 317 nativePusher (address) ┐ address(20) + uint32(4) + bool(1) + // minimumDelaySeconds (uint32) │ = 25 bytes → PACKED into one slot + // securityPerimeterPaused (bool) ┘ + // 318 wrbtc (address; canonical WRBTC — own slot) + // 319 .. 350 __gap[32] (50 − 18 own slots) + // + // own_slots = 18 (admin, lastRequestId, 8 mapping heads, 3 EnumerableSet ×2, + // the packed nativePusher+minimumDelaySeconds+securityPerimeterPaused slot, + // and wrbtc). __gap = 50 − 18 = 32. Re-derive at build time with + // `forge inspect ExitDelayQueue storageLayout` before any deployment and + // set __gap accordingly. Upgrades adding storage MUST consume from __gap. + // + // Stuck-exit recovery redesign the + // markPayoutFailed / _payoutFailed recovery marker AND the earlier + // executeExit(id, altReceiver) redirect overload were BOTH removed. A bouncing + // honest recipient is handled self-service by {originator, owner} via the + // dedicated recoverStuckExit(id, altReceiver) leg — which attempts the STORED + // receiver FIRST and pays altReceiver only on a genuine bounce (verify-by- + // attempting; a healthy exit is NEVER redirected). No stored failure flag, no + // admin/Owner recovery path, no request re-targeting. The all-four-actor + // block gate covers the STORED receiver so a blocked original receiver + // refuses recovery and falls to Leg-3. The freed marker slot returns to __gap + // (restoring the pre-marker layout, still 32). + + /// @notice Fast operational guardian. Not an OZ AccessControl role — + /// a single stored address checked by `onlyAdminOrOwner`. MUST be + /// distinct from the Owner for the authority bounds to + /// hold; enforced at `initialize` and `setAdmin`. + address public admin; + + /// @notice Monotonic id source; ids are never reused. The first + /// recorded id is 1 (0 is reserved as "no request" / arbitrary block). + uint256 public lastRequestId; + + mapping(uint256 => ExitRequest) internal _requests; + + /// @dev Executor party (originator, owner) → status==Queued request ids. + /// A freeze HOLDS but does not remove (Frozen is an address state, not + /// a request status). Dual-key when originator != owner. No + /// on-chain path iterates the full set — only O(1) add/remove and the + /// paginated `getActive` view touch it. + mapping(address => EnumerableSet.UintSet) internal _activeByParty; + + mapping(bytes32 => RecoveryRoute) internal _recoveryRoutes; + EnumerableSet.Bytes32Set internal _recoveryRouteIds; + + /// @dev surfaceId → may a topUpPool=true route be registered? Owner-set; + /// true only for lending-lender/borrower surfaces at launch. + mapping(bytes32 => bool) internal _topUpFeasible; + + /// @dev Execution & recovery treat Frozen and Blacklisted alike as "blocked"; + /// recovery-away distinguishes them. + mapping(address => BlockState) internal _blockState; + EnumerableSet.AddressSet internal _blockedAccounts; // Frozen ∪ Blacklisted, for getters + + /// @dev addr → exit-request id that triggered the block (0 = arbitrary block). + mapping(address => uint256) internal _blockTrigger; + + mapping(address => bool) internal _allowedSource; + EnumerableSet.AddressSet internal _allowedSources; + + /// @dev token => Σ amounts of Queued requests (solvency). + /// address(0) = native RBTC. Exposed via the `totalEscrowed(address)` + /// view (not a public auto-getter so the interface signature is exact). + mapping(address => uint256) internal _totalEscrowed; + + /// @notice The single registered native pusher (Zero `ActivePool`), Owner-set. + /// NOTE: `receive()` is now UNCONDITIONAL and no longer reads + /// this slot — the pusher is no longer a `receive()`-time gate. The + /// field + setter are RETAINED (unchanged ABI/packing, so `__gap` + /// stays 32) as documented provenance of the intended native source; + /// `_allowedSource` still gates the record CALLER at ingress. + address public nativePusher; + + /// @notice F1 floor enforced PER-REQUEST at ingress. Packs with + /// nativePusher + securityPerimeterPaused. + uint32 public minimumDelaySeconds; + + /// @notice Pauses executeExit(s) ONLY; ingress + recovery stay live. + /// Packs with nativePusher (address) + minimumDelaySeconds (uint32). + bool public securityPerimeterPaused; + + /// @notice Canonical WRBTC address. Set at initialize; used only to + /// guard the `unwrapOnDelivery` flag and to unwrap at delivery. A + /// full own slot (slot 318 in the layout note above), so __gap is + /// 50 − 18 = 32. Re-derive via `forge inspect` before deployment. + address public wrbtc; + + // aderyn-ignore-next-line(unused-state-variable) + uint256[32] private __gap; + + /// @notice Max page size for the paginated `getActive` / `blockedAccounts` + /// views. Public so paging is + /// self-describing on-chain — a caller can read the cap instead of + /// hard-coding 500 and discovering the clamp empirically. + uint256 public constant MAX_GET_ACTIVE_PAGE = 500; + + // ─── Modifiers ────────────────────────────────────────────────────── + + modifier onlyAdminOrOwner() { + if (msg.sender != admin && msg.sender != owner()) revert NotAdminOrOwner(msg.sender); + _; + } + + modifier onlyAllowedSource() { + if (!_allowedSource[msg.sender]) revert UnregisteredSource(msg.sender); + _; + } + + error NotAdminOrOwner(address caller); + error OwnershipCannotBeRenounced(); + error UpgradeImplZero(); + + // ─── Construction / initialization ────────────────────────────────── + + constructor() { + _disableInitializers(); + } + + /// @notice Initialize the proxy. Deployer becomes the initial Owner; the + /// bootstrap flow then `transferOwnership` → the governance Owner. + /// @param owner_ Owner principal (0 or msg.sender ⇒ deployer stays owner). + /// @param admin_ Fast guardian; non-zero. MAY equal the owner ( + /// retired, deliberate: launch shape is admin = owner + /// = governance Safe; the split becomes a real authority bound only + /// when ownership later moves to Bitocracy). + /// @param wrbtc_ Canonical WRBTC token. Must be non-zero. + /// @param minimumDelaySeconds_ Per-request delay floor. + /// @param initialAllowedSources Hooked source contracts. + function initialize( + address owner_, + address admin_, + address wrbtc_, + uint32 minimumDelaySeconds_, + address[] calldata initialAllowedSources + ) external initializer { + __Ownable_init(); + __Ownable2Step_init(); + __ReentrancyGuard_init(); + __UUPSUpgradeable_init(); + + if (wrbtc_ == address(0) || admin_ == address(0)) revert ZeroAddress(); + wrbtc = wrbtc_; + + admin = admin_; + + minimumDelaySeconds = minimumDelaySeconds_; + + for (uint256 i = 0; i < initialAllowedSources.length; ++i) { + address src = initialAllowedSources[i]; + if (src == address(0)) revert ZeroAddress(); + if (_allowedSources.add(src)) { + _allowedSource[src] = true; + emit AllowedSourceSet(src, true); + } + } + + if (owner_ != address(0) && owner_ != msg.sender) { + _transferOwnership(owner_); + } + } + + // ─── Ingress ────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function recordERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + // guard: the delivery-time unwrap can only be set on WRBTC escrow. + if (unwrapOnDelivery && token != wrbtc) revert UnwrapNonWrbtc(); + _validateIngress(token, amount, delaySeconds, effOrig, effOwner, receiver); + + // ERC20 pull with receipt proof (High-3): measure before/after so a + // fee-on-transfer/rebasing token cannot silently mis-escrow. + uint256 before = IERC20(token).balanceOf(address(this)); + IERC20(token).safeTransferFrom(msg.sender, address(this), amount); + uint256 received = IERC20(token).balanceOf(address(this)) - before; + if (received != amount) revert ReceivedAmountMismatch(token, received, amount); + + id = _record( + token, amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, unwrapOnDelivery + ); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Measured-delta path (0.5.x): the source pushes `amount` and records + /// in the SAME outer tx. We measure the current non-backing surplus + /// `delta = balanceOf − totalEscrowed` and require `delta >= amount`: + /// the record CONSUMES exactly `amount` into + /// totalEscrowed; any excess (pre-existing dust, a force-sent/donated + /// 1-wei, another source's surplus) stays as non-backing surplus for + /// sweepSurplus and is NEVER mis-credited. The earlier `== amount` exact + /// rule was donation-griefable — a 1-wei force-send permanently reverted + /// every subsequent record. Because push and record are atomic in one + /// outer tx, there is no interleaved-push residual to protect against, + /// and a donation only RAISES the surplus, so `>= amount` still passes. + function recordReceivedERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + _validateIngress(token, amount, delaySeconds, effOrig, effOwner, receiver); + + // Non-backing surplus = (current backing balance) − (already-escrowed). + // Require it covers `amount` (delta >= amount). Revert only when + // the push under-delivered (delta < amount ⇒ ReceivedAmountMismatch). + // Excess over `amount` is left as sweepable surplus (never mis-credited). + uint256 backing = IERC20(token).balanceOf(address(this)); + uint256 escrowed = _totalEscrowed[token]; + uint256 delta = backing > escrowed ? backing - escrowed : 0; + if (delta < amount) revert ReceivedAmountMismatch(token, delta, amount); + + id = _record(token, amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false); + } + + /// @inheritdoc IExitDelayQueue + function recordNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable nonReentrant onlyAllowedSource returns (uint256 id) { + if (msg.value != amount) revert AmountMismatch(msg.value, amount); + _validateIngress(address(0), amount, delaySeconds, effOrig, effOwner, receiver); + id = _record( + address(0), amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false + ); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Native measured-receipt (Zero): `ActivePool.sendETH(queue, amount)` + /// pushes value (via `receive()`) BEFORE this record in the same outer + /// tx; a record revert rolls the push back (fail-closed). Same + /// rule as the ERC20 measured path: require the non-backing + /// surplus `delta >= amount` and credit exactly `amount`. The + /// `receive()` gate cannot stop a `selfdestruct` force-send, which is + /// exactly why crediting must tolerate surplus (`>= amount`) rather than + /// require an exact balance — a force-sent 1-wei must not + /// permanently brick every subsequent Zero exit record. + function recordReceivedNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external nonReentrant onlyAllowedSource returns (uint256 id) { + _validateIngress(address(0), amount, delaySeconds, effOrig, effOwner, receiver); + uint256 backing = address(this).balance; + uint256 escrowed = _totalEscrowed[address(0)]; + uint256 delta = backing > escrowed ? backing - escrowed : 0; + if (delta < amount) revert ReceivedAmountMismatch(address(0), delta, amount); + id = _record( + address(0), amount, delaySeconds, surfaceId, subProduct, effOrig, effOwner, receiver, false + ); + } + + /// @notice UNCONDITIONAL native-RBTC sink. Accepts + /// native from ANYONE with **no storage reads and no sender gate**. + /// + /// Why unconditional: the real Rootstock WRBTC `withdraw()` returns + /// native via a 2300-gas `transfer` stipend. Any storage-slot sender + /// check here (SLOAD ≥ 2100 cold under EIP-2929/Paris) exceeds that + /// stipend, so a sender-gated `receive()` `OutOfGas`-bricks every + /// `unwrapOnDelivery` (native `burnToBTC`) payout after unlock — + /// empirically reproduced (`ExitDelayQueueUnwrapStipend`). Dropping + /// the gate is SAFE because the already neutralizes stray or + /// donated native: the two measured-receipt ingress paths credit + /// EXACTLY `amount` when the non-backing surplus `>= amount` and never + /// mis-credit, so unsolicited RBTC (including a `selfdestruct` + /// force-send the old gate could not stop anyway) only accrues as + /// `sweepSurplus`-able surplus. Accepted trade-off: the queue no + /// longer asserts "only ActivePool pays in native" — defense-in-depth + /// the made redundant. + receive() external payable virtual {} + + // ─── Ingress helpers ──────────────────────────────────────────────── + + function _validateIngress( + address, /*token — reserved for future per-token gating*/ + uint128 amount, + uint32 delaySeconds, + address effOrig, + address effOwner, + address receiver + ) internal view { + if (amount == 0) revert ZeroAmount(); + // AmountTooLarge: the record* ABI takes `amount` as uint128 + // deliberately (keeps ExitRequest word-1 packing). The uint256→ + // uint128 narrowing therefore happens in the CALLER (the ColFee hook / + // 0.5.x product host), which MUST `require(userAmount <= type(uint128).max) + // else AmountTooLarge` BEFORE narrowing — that check is the live + // queue-boundary guard, in the caller's pragma, against silent truncation + // (IExitDelayQueue NatSpec). The queue re-asserting it on an already- + // uint128 arg would be a compile-time tautology, so the guard is kept at + // the boundary where a uint256 actually exists, not duplicated as dead code + // here. We still reject a below-floor delay and zero-address parties. + if (delaySeconds < minimumDelaySeconds) revert DelayBelowFloor(delaySeconds, minimumDelaySeconds); + if (effOrig == address(0) || effOwner == address(0) || receiver == address(0)) revert ZeroAddress(); + } + + function _record( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) internal returns (uint256 id) { + id = ++lastRequestId; + + // Write field-by-field into storage to keep the stack shallow (a struct + // literal with 11 members + the 9-arg event blows the 0.8.20 stack + // without via-ir; this repo pins non-via-ir to match the deployed + // ColFee bytecode profile). + ExitRequest storage r = _requests[id]; + r.amount = amount; + r.createdAt = uint64(block.timestamp); + r.unlockAt = uint64(block.timestamp + delaySeconds); + r.originator = effOrig; + r.owner = effOwner; + r.receiver = receiver; + r.token = token; + r.surfaceId = surfaceId; + r.subProduct = subProduct; + r.status = ExitStatus.Queued; + r.unwrapOnDelivery = unwrapOnDelivery; + + // Dual-key insert; EnumerableSet dedups when originator == owner. + _activeByParty[effOrig].add(id); + _activeByParty[effOwner].add(id); + + _totalEscrowed[token] += amount; + + emit ExitQueued(id, effOrig, effOwner, receiver, token, amount, r.unlockAt, surfaceId, subProduct); + } + + // ─── Execution ─────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + /// @dev Pays the request's immutable `receiver`. A reverting receiver rolls + /// the whole call back (fail-closed) — the request stays Queued and + /// the rightful parties retry via `executeExit` (once the recipient is + /// fixed) or `recoverStuckExit` (redirect leg). No redirect here. + function executeExit(uint256 requestId) external nonReentrant { + _executeOne(requestId); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Strict array order; atomic (any revert rolls the whole batch back). + /// A duplicate id flips to Executed on the first pass, then + /// hits AlreadyTerminal on the second — never double-pays. Always pays + /// the immutable receiver (no redirect in the batch path). + function executeExits(uint256[] calldata ids) external nonReentrant { + if (ids.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < ids.length; ++i) { + _executeOne(ids[i]); + } + } + + /// @dev Shared execution core — always pays the immutable `receiver`. + /// Block gate covers `{originator, owner, receiver}`. CEI: terminal + /// status + escrow decrement + set removal ALL before the external transfer + /// (`nonReentrant`). + function _executeOne(uint256 requestId) internal { + if (securityPerimeterPaused) revert QueuePaused(); + ExitRequest storage r = _requests[requestId]; + if (r.status == ExitStatus.None) revert UnknownRequest(requestId); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(requestId); + if (block.timestamp < r.unlockAt) revert NotUnlocked(requestId, r.unlockAt); + if (msg.sender != r.originator && msg.sender != r.owner) revert NotExecutor(msg.sender); + + _requireNotBlocked(r.originator); + _requireNotBlocked(r.owner); + _requireNotBlocked(r.receiver); + + address token = r.token; + uint128 amount = r.amount; + address receiver = r.receiver; + bool unwrap = r.unwrapOnDelivery; + + r.status = ExitStatus.Executed; + _removeActive(requestId, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitExecuted(requestId, receiver, token, amount); + _payout(token, receiver, amount, unwrap); + } + + // ─── Stuck-exit recovery — verify-by-attempting redirect leg ── + + /// @inheritdoc IExitDelayQueue + /// @dev Stuck-exit recovery redesign. + /// A bouncing honest recipient is not a perimeter-specific problem (the + /// same withdrawal would bounce without the delay), so recovery is + /// SELF-SERVICE by the frozen-metadata `{originator, owner}` set (the + /// receiver is NEVER an executor) — no admin/Owner path, NO stored failure + /// flag, and the stored request is NEVER re-targeted (`altReceiver` is a + /// payout-time destination only, so + hold). + /// + /// VERIFY-BY-ATTEMPTING: after CEI (status → Executed, escrow decremented, + /// sets pruned), the STORED-receiver payout is attempted FIRST. If it + /// SUCCEEDS, that is the payout and `altReceiver` is unused — a HEALTHY + /// exit is NEVER redirected (no arbitrary redirect; Model-B stays + /// rejected). Only if the stored-receiver payout genuinely REVERTS do we + /// pay `altReceiver`; if `altReceiver` also fails, the whole call reverts + /// and the funds stay Queued (CEI rollback). + /// + /// ALL-FOUR-ACTOR block gate: none of `{originator, owner, STORED + /// receiver, altReceiver}` may be Frozen/Blacklisted. Gating the STORED + /// receiver is the must-fix — a blocked/hacked original receiver refuses + /// recovery entirely (this leg can never move its funds), keeping the + /// blacklist-trap and the / receiver-only dead-end intact (that case + /// is Leg-3's). `altReceiver` is guarded: not 0/this/token/wrbtc. + function recoverStuckExit(uint256 id, address altReceiver) external nonReentrant { + if (securityPerimeterPaused) revert QueuePaused(); + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + if (block.timestamp < r.unlockAt) revert NotUnlocked(id, r.unlockAt); + if (msg.sender != r.originator && msg.sender != r.owner) revert NotExecutor(msg.sender); + + address token = r.token; + address receiver = r.receiver; + + // altReceiver guard: a payout-time destination, never the zero + // address, this contract (would trap escrow), the escrowed token, or WRBTC + // (a wrapped-token destination would silently swallow an unwrap payout). + if ( + altReceiver == address(0) || altReceiver == address(this) || altReceiver == token + || altReceiver == wrbtc + ) revert InvalidAltReceiver(altReceiver); + + // All-four-actor block gate: originator, owner, the STORED receiver, + // and altReceiver must all be unblocked. Gating the STORED receiver means a + // blocked/hacked original receiver refuses recovery here (→ Leg-3). + _requireNotBlocked(r.originator); + _requireNotBlocked(r.owner); + _requireNotBlocked(receiver); + _requireNotBlocked(altReceiver); + + // CEI: terminal status + escrow decrement + set removal BEFORE + // any external transfer, so a failed attempt or reentry cannot double-spend. + uint128 amount = r.amount; + bool unwrap = r.unwrapOnDelivery; + + r.status = ExitStatus.Executed; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + // Attempt the STORED-receiver payout first (a healthy exit pays here and + // altReceiver is never used, even when altReceiver == receiver). Only on a + // GENUINE bounce do we fall through to altReceiver — tracked by an explicit + // bool, NOT an address compare, so a request whose stored receiver equals + // altReceiver is paid exactly ONCE. + address paid; + if (_tryPayout(token, receiver, amount, unwrap)) { + paid = receiver; + } else { + // Original bounced — pay altReceiver; if THIS also fails, the whole call + // reverts (CEI rollback leaves the request Queued). + _payout(token, altReceiver, amount, unwrap); + paid = altReceiver; + } + emit ExitExecuted(id, paid, token, amount); + } + + /// @dev Catchable single-payout attempt for `recoverStuckExit`. Routes the + /// transfer through an EXTERNAL self-call so a reverting recipient is + /// caught (Solidity cannot catch a low-level revert inline) and the leg + /// can fall through to `altReceiver`. Returns false on any failure. + /// Self-only (`msg.sender == address(this)`); NOT `nonReentrant` — it runs + /// inside `recoverStuckExit`'s guard, and CEI already made the state safe. + function _tryPayout(address token, address to, uint128 amount, bool unwrap) internal returns (bool) { + try this.payoutExternal(token, to, amount, unwrap) { + return true; + } catch { + return false; + } + } + + /// @notice Internal payout trampoline — only callable by the contract itself + /// (via `_tryPayout`). Reverts on a bouncing recipient so the caller's + /// try/catch can fall through. Not part of the external ABI surface for + /// any other caller (the `SelfOnly` guard makes a direct call revert). + function payoutExternal(address token, address to, uint128 amount, bool unwrap) external { + if (msg.sender != address(this)) revert SelfOnly(); + _payout(token, to, amount, unwrap); + } + + // ─── Block model ────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function freezeFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + _blockFromRequest(requestId, freezeReceiver, reasonHash, BlockState.Frozen); + } + + /// @inheritdoc IExitDelayQueue + function blacklistFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + _blockFromRequest(requestId, freezeReceiver, reasonHash, BlockState.Blacklisted); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Batch by-request-id. Resolves each + /// request's `{originator, owner}` (+ `receiver` if `freezeReceiver`) and + /// blocks all in ONE tx. Whole-batch ATOMIC: one unknown id reverts the + /// whole batch (like `executeExits`). Last-write-wins trigger/reason per + /// The emergency speed lever: block every owner + delegate behind a + /// set of known-malicious requests in a single Admin-multisig call. + function freezeFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + if (requestIds.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < requestIds.length; ++i) { + _blockFromRequest(requestIds[i], freezeReceiver, reasonHash, BlockState.Frozen); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev Batch by-request-id blacklist. Same atomicity + last-write- + /// wins semantics as the batch freeze above; a Frozen→Blacklisted + /// escalation within a batch is handled by `_setBlock`. + function blacklistFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external + onlyAdminOrOwner + { + if (requestIds.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < requestIds.length; ++i) { + _blockFromRequest(requestIds[i], freezeReceiver, reasonHash, BlockState.Blacklisted); + } + } + + function _blockFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash, BlockState to) + internal + { + ExitRequest storage r = _requests[requestId]; + if (r.status == ExitStatus.None) revert UnknownRequest(requestId); + // originator always; owner too iff distinct; receiver only if flagged. + _setBlock(r.originator, to, requestId, reasonHash); + if (r.owner != r.originator) _setBlock(r.owner, to, requestId, reasonHash); + if (freezeReceiver) _setBlock(r.receiver, to, requestId, reasonHash); + } + + /// @inheritdoc IExitDelayQueue + function freeze(address a) external onlyAdminOrOwner { + _setBlock(a, BlockState.Frozen, 0, bytes32(0)); + } + + /// @inheritdoc IExitDelayQueue + function blacklist(address a) external onlyAdminOrOwner { + _setBlock(a, BlockState.Blacklisted, 0, bytes32(0)); + } + + /// @inheritdoc IExitDelayQueue + function unfreeze(address a) external onlyAdminOrOwner { + _clearBlock(a, BlockState.Frozen); + } + + /// @inheritdoc IExitDelayQueue + function unblacklist(address a) external onlyAdminOrOwner { + _clearBlock(a, BlockState.Blacklisted); + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard: an empty array is a caller mistake, not a + /// silent no-op — for consistency with the by-id batch variants. + function freeze(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _setBlock(a[i], BlockState.Frozen, 0, bytes32(0)); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function blacklist(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _setBlock(a[i], BlockState.Blacklisted, 0, bytes32(0)); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function unfreeze(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _clearBlock(a[i], BlockState.Frozen); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev EmptyIds guard. + function unblacklist(address[] calldata a) external onlyAdminOrOwner { + if (a.length == 0) revert EmptyIds(); + for (uint256 i = 0; i < a.length; ++i) { + _clearBlock(a[i], BlockState.Blacklisted); + } + } + + /// @dev Set (or escalate) a block. Frozen→Blacklisted is atomic (no unfreeze + /// first). Re-block on an already-`to` state is a state no-op that + /// refreshes trigger + reason (last-write-wins). + function _setBlock(address a, BlockState to, uint256 triggerId, bytes32 reasonHash) internal { + if (a == address(0)) revert ZeroAddress(); + // No Blacklisted → Frozen downgrade: a blacklist is only cleared by + // unblacklist. A `freeze` on an already-Blacklisted address is a no-op + // that still refreshes trigger/reason (does not downgrade). + BlockState from = _blockState[a]; + if (to == BlockState.Frozen && from == BlockState.Blacklisted) { + // hold the stronger state; refresh evidence only + _blockTrigger[a] = triggerId; + emit AccountBlocked(a, from, triggerId, reasonHash); + return; + } + if (from == BlockState.None) { + _blockedAccounts.add(a); + } + _blockState[a] = to; + _blockTrigger[a] = triggerId; + emit AccountBlocked(a, to, triggerId, reasonHash); + } + + /// @dev Clear a block. `expected` selects which removal fn ran: unfreeze + /// requires Frozen, unblacklist requires Blacklisted (wrong-removal + /// reverts — footgun guard). Absent (`None`) always reverts. + function _clearBlock(address a, BlockState expected) internal { + BlockState from = _blockState[a]; + if (expected == BlockState.Frozen) { + if (from != BlockState.Frozen) revert NotFrozen(a); + } else { + // expected == Blacklisted + if (from != BlockState.Blacklisted) revert NotBlacklisted(a); + } + _blockState[a] = BlockState.None; + _blockTrigger[a] = 0; + _blockedAccounts.remove(a); + emit AccountUnblocked(a, from); + } + + function _requireNotBlocked(address a) internal view { + BlockState s = _blockState[a]; + if (s != BlockState.None) revert ActorBlocked(a, s); + } + + // ─── Pause ─────────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function setSecurityPerimeterPaused(bool p) external onlyAdminOrOwner { + securityPerimeterPaused = p; + emit SecurityPerimeterPausedSet(p); + } + + // ─── Recovery ──────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + /// @dev Leg-2: recover once `isBlacklisted(originator) || isBlacklisted(owner)` + /// (OR predicate); exact provenance match to an active route; a + /// mixed batch reverts wholesale. + function resolveToProtocol(uint256[] calldata ids, bytes32 routeId) + external + nonReentrant + onlyAdminOrOwner + { + if (ids.length == 0) revert EmptyIds(); + RecoveryRoute storage route = _recoveryRoutes[routeId]; + if (!route.active) revert RouteInactive(routeId); + for (uint256 i = 0; i < ids.length; ++i) { + uint256 id = ids[i]; + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + + // OR-blacklist authorization over source parties; receiver-only + // block never authorizes Leg-2. + bool authorized = _blockState[r.originator] == BlockState.Blacklisted + || _blockState[r.owner] == BlockState.Blacklisted; + if (!authorized) revert SourceNotBlacklisted(r.originator); + + // Exact provenance: surface, subProduct, token must match. + if (r.surfaceId != route.surfaceId || r.subProduct != route.subProduct || r.token != route.token) + { + revert RouteProvenanceMismatch(id, routeId); + } + + address token = r.token; + uint128 amount = r.amount; + r.status = ExitStatus.ResolvedToProtocol; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitResolvedToProtocol(id, routeId, route.destination, amount); + // topUpPool routes are a plain token top-up to the pool + // (destination == subProduct); both branches use the same primitive. + _payout(token, route.destination, amount, false); + } + } + + /// @inheritdoc IExitDelayQueue + /// @dev Leg-3: Owner catch-all, bounded to a blocked/held/non-executable + /// request — the DAO can never touch an honest, fully-unblocked, + /// unlocked, in-flight exit. + function resolveBySIP(uint256[] calldata ids, address destination) external nonReentrant onlyOwner { + if (ids.length == 0) revert EmptyIds(); + if (destination == address(0)) revert ZeroAddress(); + for (uint256 i = 0; i < ids.length; ++i) { + uint256 id = ids[i]; + ExitRequest storage r = _requests[id]; + if (r.status == ExitStatus.None) revert UnknownRequest(id); + if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); + + // Bounded predicate: blocked | paused | locked. The DAO can never + // touch an honest, fully-unblocked, unlocked, unpaused in-flight exit. + // A bouncing (but unblocked) recipient is NOT admitted here — it is + // handled self-service via recoverStuckExit(id, altReceiver), + // so there is no _payoutFailed term (that mechanism was removed). + bool resolvable = _isBlocked(r.originator) || _isBlocked(r.owner) || _isBlocked(r.receiver) + || securityPerimeterPaused || block.timestamp < r.unlockAt; + if (!resolvable) revert NotResolvableBySIP(id); + + address token = r.token; + uint128 amount = r.amount; + r.status = ExitStatus.ResolvedBySIP; + _removeActive(id, r.originator, r.owner); + _totalEscrowed[token] -= amount; + + emit ExitResolvedBySIP(id, destination, amount); + _payout(token, destination, amount, false); + } + } + + function _isBlocked(address a) internal view returns (bool) { + return _blockState[a] != BlockState.None; + } + + /// @inheritdoc IExitDelayQueue + function setRecoveryRoute(RecoveryRoute calldata route) external onlyOwner returns (bytes32 routeId) { + if (route.destination == address(0)) revert ZeroAddress(); + // topUpPool routes restricted on-chain to feasible surfaces and + // to non-native tokens (a native request can never be Leg-2a). + if (route.topUpPool) { + if (!_topUpFeasible[route.surfaceId]) revert TopUpInfeasibleSurface(route.surfaceId); + if (route.token == address(0)) revert TopUpInfeasibleSurface(route.surfaceId); + } + routeId = keccak256(abi.encode(route.surfaceId, route.subProduct, route.token, route.destination)); + _recoveryRoutes[routeId] = route; + _recoveryRouteIds.add(routeId); + emit RecoveryRouteSet( + routeId, route.surfaceId, route.subProduct, route.token, route.destination, route.topUpPool + ); + } + + /// @inheritdoc IExitDelayQueue + function removeRecoveryRoute(bytes32 routeId) external onlyOwner { + delete _recoveryRoutes[routeId]; + _recoveryRouteIds.remove(routeId); + emit RecoveryRouteRemoved(routeId); + } + + /// @inheritdoc IExitDelayQueue + function setTopUpFeasible(bytes32 surfaceId, bool feasible) external onlyOwner { + _topUpFeasible[surfaceId] = feasible; + emit TopUpFeasibleSet(surfaceId, feasible); + } + + // ─── Config ────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function addAllowedSource(address src) external onlyOwner { + if (src == address(0)) revert ZeroAddress(); + if (_allowedSources.add(src)) { + _allowedSource[src] = true; + emit AllowedSourceSet(src, true); + } + } + + /// @inheritdoc IExitDelayQueue + function removeAllowedSource(address src) external onlyOwner { + if (_allowedSources.remove(src)) { + _allowedSource[src] = false; + emit AllowedSourceSet(src, false); + } + } + + /// @inheritdoc IExitDelayQueue + function setNativePusher(address pusher) external onlyOwner { + nativePusher = pusher; + emit NativePusherSet(pusher); + } + + /// @notice Rotate the Admin guardian. Non-zero; MAY equal the Owner. + function setAdmin(address newAdmin) external onlyOwner { + if (newAdmin == address(0)) revert ZeroAddress(); + admin = newAdmin; + emit AdminSet(newAdmin); + } + + event AdminSet(address indexed admin); + + /// @inheritdoc IExitDelayQueue + function setMinimumDelaySeconds(uint32 s) external onlyOwner { + minimumDelaySeconds = s; + emit MinimumDelaySet(s); + } + + /// @inheritdoc IExitDelayQueue + /// @dev Moves EXACTLY the non-backing surplus (balanceOf − totalEscrowed); + /// provably never touches escrowed backing via the post-sweep solvency + /// require. Unlocks the equality form of /. + function sweepSurplus(address token, address to) external nonReentrant onlyOwner { + if (to == address(0)) revert SweepToZero(); + uint256 escrowed = _totalEscrowed[token]; + if (token == address(0)) { + uint256 bal = address(this).balance; + uint256 surplus = bal > escrowed ? bal - escrowed : 0; + emit SurplusSwept(token, to, surplus); + if (surplus > 0) Address.sendValue(payable(to), surplus); + if (address(this).balance < escrowed) revert SolvencyViolated(); + } else { + uint256 bal = IERC20(token).balanceOf(address(this)); + uint256 surplus = bal > escrowed ? bal - escrowed : 0; + emit SurplusSwept(token, to, surplus); + if (surplus > 0) IERC20(token).safeTransfer(to, surplus); + if (IERC20(token).balanceOf(address(this)) < escrowed) revert SolvencyViolated(); + } + } + + // ─── Payout primitive ─────────────────────────────────────────────── + + /// @dev ERC20 safeTransfer / native sendValue / WRBTC-unwrap-then-sendValue. + /// No fail-open: a reverting receiver rolls the whole call back + /// (status already terminal → held until Leg-3 redirects). + function _payout(address token, address to, uint128 amount, bool unwrap) internal { + if (token == address(0)) { + Address.sendValue(payable(to), amount); + } else if (unwrap) { + // WRBTC-escrowed: unwrap to native, then send native (Option B). + IWRBTC(token).withdraw(amount); + Address.sendValue(payable(to), amount); + } else { + IERC20(token).safeTransfer(to, amount); + } + } + + // ─── Active-index maintenance ─────────────────────────────────────── + + /// @dev Remove an id from both party sets; single removal when equal. + function _removeActive(uint256 id, address originator, address owner_) internal { + _activeByParty[originator].remove(id); + if (owner_ != originator) _activeByParty[owner_].remove(id); + } + + // ─── Views ─────────────────────────────────────────────────── + + /// @inheritdoc IExitDelayQueue + function getRequest(uint256 id) external view returns (ExitRequest memory) { + return _requests[id]; + } + + /// @inheritdoc IExitDelayQueue + /// @dev Best-effort over a mutating set: a concurrent removal can + /// skip/repeat; the ExitQueued/ExitExecuted events are the authoritative + /// reconstruction source. `nextCursor == 0` signals end. + function getActive(address party, uint256 cursor, uint256 n) + external + view + returns (uint256[] memory ids, uint256 nextCursor) + { + if (n > MAX_GET_ACTIVE_PAGE) n = MAX_GET_ACTIVE_PAGE; + EnumerableSet.UintSet storage set = _activeByParty[party]; + uint256 len = set.length(); + if (cursor >= len || n == 0) { + return (new uint256[](0), 0); + } + uint256 end = cursor + n; + if (end > len) end = len; + ids = new uint256[](end - cursor); + for (uint256 i = cursor; i < end; ++i) { + ids[i - cursor] = set.at(i); + } + nextCursor = end >= len ? 0 : end; + } + + /// @inheritdoc IExitDelayQueue + function blockStateOf(address a) external view returns (BlockState) { + return _blockState[a]; + } + + /// @inheritdoc IExitDelayQueue + function blockedAccounts(uint256 offset, uint256 limit) + external + view + returns (address[] memory page, uint256 total) + { + // Return the FULL blocked-set size `total` alongside the clamped `page`: + // a monitor/sanctions integrator paging + // this view knows the exact range ("showing offset..offset+page.length of + // total") and can never silently undercount past the 500 cap. `total` is + // the true EnumerableSet length regardless of offset/limit. + // + // Page clamp/cap (retained): cap the page size at + // MAX_GET_ACTIVE_PAGE and derive `end` from a bounded `limit` so + // `offset + limit` can never overflow-revert (a griefy caller passing a + // near-max offset/limit). + uint256 len = _blockedAccounts.length(); + total = len; + if (limit > MAX_GET_ACTIVE_PAGE) limit = MAX_GET_ACTIVE_PAGE; + if (offset >= len || limit == 0) return (new address[](0), total); + uint256 end = offset + limit; + if (end > len) end = len; + page = new address[](end - offset); + for (uint256 i = offset; i < end; ++i) { + page[i - offset] = _blockedAccounts.at(i); + } + } + + /// @inheritdoc IExitDelayQueue + function blockTrigger(address a) external view returns (uint256) { + return _blockTrigger[a]; + } + + /// @inheritdoc IExitDelayQueue + function totalEscrowed(address token) external view returns (uint256) { + return _totalEscrowed[token]; + } + + /// @inheritdoc IExitDelayQueue + function getRecoveryRoute(bytes32 routeId) external view returns (RecoveryRoute memory) { + return _recoveryRoutes[routeId]; + } + + /// @inheritdoc IExitDelayQueue + function allowedSources() external view returns (address[] memory) { + return _allowedSources.values(); + } + + /// @notice Enumerate registered recovery route ids (owner tooling). + function recoveryRouteIds() external view returns (bytes32[] memory) { + return _recoveryRouteIds.values(); + } + + /// @notice Whether a topUpPool route may be registered for `surfaceId`. + function topUpFeasible(bytes32 surfaceId) external view returns (bool) { + return _topUpFeasible[surfaceId]; + } + + /// @notice Whether `src` is a registered ingress source. + function isAllowedSource(address src) external view returns (bool) { + return _allowedSource[src]; + } + + // ─── Upgrade authorization (UUPS) ─────────────────────────────────── + + // aderyn-ignore-next-line(centralization-risk) + function _authorizeUpgrade(address newImplementation) internal view override onlyOwner { + if (newImplementation == address(0)) revert UpgradeImplZero(); + } + + /// @dev Disabled: an ownerless custody contract would lock escrowed funds + /// forever (no execute-side auth changes, but no upgrade / no config / + /// no recovery-config). The 2-step transfer is the only admin move. + function renounceOwnership() public pure override { + revert OwnershipCannotBeRenounced(); + } + + // NOTE the + // `_transferOwnership` chokepoint override (Admin != Owner enforced on + // the ownership side) was REMOVED together with the initialize/setAdmin + // owner-equality checks — admin == owner is a supported shape (the + // governance Safe holds both roles at launch). Consequence, accepted: + // while the roles coincide, the Leg-2/Leg-3 authority split and + // the bounds are vacuous; they become real when ownership moves to + // Bitocracy. +} diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index aae1f13..e37582c 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -57,16 +57,55 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 254 _actorPolicy mapping head // 255 _subProductKeys mapping head (enumeration index) // 256 _actorKeys mapping head (enumeration index) - // 257 ExitFeeController.admin (address, 20 bytes; slot offset 0, - // 12 bytes free). - // 258 .. 300 __gap[43] -- preserves the OZ-style 50-slot namespace - // (50 - 7 own slots used). + // + // ── Delay extension (added BELOW the fee slots) ── + // 257 securityPerimeterEnabled (1 byte) + globalDelaySeconds (4) + + // admin (20 bytes) = 25 bytes -- PACKED into ONE slot. (admin + // is a SINGLE guardian address, NOT an OZ AccessControl role, + // the controller stays single-Owner; only the queue carries + // a two-principal machinery. The perimeter kill switch + // and -- since the core merge -- the + // fee levers `setExitFeeEnabled` / `setFeeReceiver` need an + // `Admin`-capable path, so one packed address is the minimal + // addition and costs ZERO extra slots by packing with the + // bool + uint32. The SAME field serves both gates.) + // 258 _surfaceBypass mapping head + // 259 _subProductBypass mapping head + // 260 _actorBypass mapping head + // 261 _surfaceBypassKeys._values (Bytes32Set array head) ┐ 2 slots + // 262 _surfaceBypassKeys._indexes (mapping head) ┘ + // 263 _subProductBypassKeys mapping head (enumeration index) + // 264 _actorBypassKeys mapping head (enumeration index) + // 265 _passthroughActor nested-mapping head (surface-scoped) + // 266 _passthroughKeys mapping head (enumeration index) + // 267 _bypassSurfaceIds._values (Bytes32Set array head) ┐ 2 slots + // 268 _bypassSurfaceIds._indexes (mapping head) ┘ + // 269 _passthroughSurfaceIds._values (Bytes32Set array head) ┐ 2 slots + // 270 _passthroughSurfaceIds._indexes (mapping head) ┘ + // 271 .. 300 __gap[30] -- preserves the OZ-style 50-slot namespace + // (50 - 20 own slots used). + // + // Own-slot count re-derived at implementation via `forge inspect + // ExitFeeController storage-layout`: 6 fee slots + 14 delay slots + // (1 packed scalar+admin slot + 3 bypass mapping heads + 1 surface-bypass + // Bytes32Set [2 slots] + 2 sub/actor bypass enumeration heads + 1 passthrough + // nested-mapping head + 1 passthrough enumeration head + 1 any-tier-touched + // bypass Bytes32Set [2 slots] + 1 passthrough-surface Bytes32Set [2 slots]) + // = 20, so __gap = 50 - 20 = 30 (added the two + // any-tier-touched master sets on top of 's per-tier sets). + // Since ColFee is not yet deployed, this is a first-deploy layout choice, + // not a UUPS migration. + // + // Merge note: the core branch declared a standalone + // `admin` at slot 257 (__gap[43]); this branch's packed slot-257 + // `admin` absorbs it -- one field, both gates, layout above unchanged. // // Upgrades that add storage to THIS contract MUST consume from __gap // and reduce its length by exactly the number of slots added. They // MUST NOT reorder, insert, or change the type of any preceding slot. using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; /// @notice Global enablement flag. When false, every `quoteExitFee` /// call returns `INACTIVE` regardless of per-surface configuration. @@ -127,19 +166,123 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; + // ─── Delay extension storage ──────────────────────────────────────── + // + // `securityPerimeterEnabled` (bool, 1 byte), `globalDelaySeconds` + // (uint32, 4 bytes) and `admin` (address, 20 bytes) are declared + // consecutively and therefore share one slot. Any change to the order or + // width of these three moves `admin`, so re-derive the layout with + // `forge inspect` and re-check it against the deployed record before + // shipping an upgrade. + + /// @notice Global kill switch for the DELAY perimeter. Independent of + /// `exitFeeEnabled`: turning fees off does NOT disable the + /// perimeter, and a fee-inactive surface can still be delay-active. + /// When false, `quoteExitDelayFor` short-circuits to + /// `(0, raw, owner)` without consulting the bypass tiers, the + /// passthrough registry, or the queue. + bool public securityPerimeterEnabled; + + /// @notice One delay for EVERY surface (uint32 gives ~136 years of head + /// room). There is no per-surface delay *duration* — only the + /// per-tier bypass toggles below exempt a surface, sub-product or + /// actor. The `>= queue.minimumDelaySeconds` relationship is a + /// liveness invariant enforced PER-REQUEST in the queue, not a + /// cross-contract setter guard here: the controller never reads or + /// calls the queue. + uint32 public globalDelaySeconds; + /// @notice Fast operational guardian. A SINGLE stored address checked by /// `onlyAdminOrOwner` -- NOT an OZ AccessControl role (the /// controller stays single-Owner for configuration). It authorizes - /// only the operational levers `setExitFeeEnabled` and - /// `setFeeReceiver`; policy setters, removals, `setAdmin` itself, - /// and UUPS upgrades stay `onlyOwner`. MAY equal the owner -- - /// nothing requires the two authorities to be distinct. Unset - /// (`address(0)`) until the owner appoints one; while unset, - /// `onlyAdminOrOwner` admits only the owner. + /// the perimeter kill switch and the operational levers + /// `setExitFeeEnabled` and `setFeeReceiver`; policy setters, + /// removals, `setAdmin` itself, and UUPS upgrades stay `onlyOwner`. + /// MAY equal the owner -- nothing requires the two authorities to + /// be distinct. Unset (`address(0)`) until the owner appoints one; + /// while unset, `onlyAdminOrOwner` admits only the owner. address public admin; + /// @dev Surface-tier delay bypass. Key: `surfaceId`. Value: + /// `DelayBypassPolicy {active, bypass}`. Mirrors `_surfacePolicy`'s + /// shape but is INDEPENDENT of it — the delay resolver never + /// reads the fee `surfacePolicy.active`. + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + + /// @dev Sub-product-tier delay bypass. Outer key `surfaceId`, inner key + /// `subProduct` (non-zero). Wins over the surface tier when active. + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + + /// @dev Actor-tier delay bypass (most specific). Outer key `surfaceId`, + /// inner key `actor` (non-zero) — evaluated on `effOrig` ONLY (there is + /// NO global actor bypass). Wins over sub-product and surface. + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + + /// @dev Enumeration index for the SURFACE-tier bypass. A single + /// flat `Bytes32Set` of every `surfaceId` with a configured surface bypass + /// — surface bypasses are keyed by `surfaceId` alone and are NOT scoped by + /// a parent surface, so `surfaceBypassKeys()` takes NO argument. Backed on + /// chain so `InspectController` / monitoring can dump every surface bypass + /// (no unenumerable zero-delay state). Retains soft-retired entries + /// (`active=false`); `removeSurfaceBypass` hard-removes. + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; + + /// @dev Enumeration indexes for the sub-product / actor bypass tiers (mirror + /// `_subProductKeys` / `_actorKeys`). Entries are retained on + /// soft-retire (`active=false`); use `removeSubProductBypass` / + /// `removeActorBypass` for hard removal. Consumed by `InspectController`. + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + + /// @dev Surface-scoped passthrough-actor registry. A passthrough + /// registered for `surfaceId` resolves to the `receiver` in + /// `effectiveActor`; it NEVER collapses identities globally — the + /// wrapper is registered only under `SURFACE_LENDING_LENDER_WITHDRAW`, + /// so margin/Zero (no entry) keep `effOrig = raw`, `effOwner = owner`. + /// Co-located here (not in the escrow queue) so the hook normalizes + /// WITHOUT touching the queue, keeping the kill switch queue-independent + /// (Finding 3). + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + + /// @dev Enumeration index for the surface-scoped passthrough registry. + /// Outer key `surfaceId`, values = every passthrough + /// address registered under it. Unlike the bypass key-sets this is kept + /// exact-to-live: an address is added on register and DROPPED on + /// deregister (`setPassthroughActor(.., false)`), because a passthrough is + /// a boolean membership with no soft-retire state to preserve. Gives the + /// passthrough registry the same enumerability as the bypass tiers (no + /// events-only blind spot on a security-critical registry). + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + + /// @dev ANY-TIER-TOUCHED master surface-id set for delay bypasses. + /// Every bypass WRITER — `_writeSurfaceBypass` (via + /// `setSurfaceBypass`), `_writeSubProductBypass`, `_writeActorBypass` — + /// records its `surfaceId` here, so a surface that carries ONLY a + /// sub-product- or actor-tier bypass (the most common exemption shape, + /// actor tier) is discoverable even though it was never passed to + /// `setSurfaceBypass`. This is the root-cause fix: the per-tier key-sets + /// (`_surfaceBypassKeys` / `_subProductBypassKeys` / `_actorBypassKeys`) + /// only tell you WHICH keys exist UNDER a known surfaceId — they cannot + /// by themselves enumerate the surfaceIds. `bypassSurfaceIds()` closes + /// that gap so NO zero-delay / identity config is invisible to + /// `InspectController`. Entries are NEVER dropped (soft-retire retention), + /// so a `removeSurfaceBypass` while sub/actor entries remain live does not + /// remove the id from discovery — the inspector still probes every tier. + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; + + /// @dev ANY-TIER-TOUCHED master surface-id set for the passthrough registry. + /// `setPassthroughActor(surfaceId, .., true)` records + /// the `surfaceId` here, so a passthrough-only surface (no bypass entry, + /// not a named fee surface) is still enumerable. Like the passthrough + /// key-set it is retention-only at the SURFACE level: a surfaceId stays + /// recorded even after every passthrough under it is deregistered (the + /// per-surface `_passthroughKeys` set going empty is the live signal; + /// keeping the surfaceId costs one slot and guarantees the inspector never + /// loses the probe point). `passthroughSurfaceIds()` exposes it. + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; + // aderyn-ignore-next-line(unused-state-variable) - uint256[43] private __gap; + uint256[30] private __gap; // ─── Custom errors ────────────────────────────────────────────────── @@ -150,24 +293,17 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable error LengthMismatch(); error OwnershipCannotBeRenounced(); error UpgradeImplZero(); + + // ─── Custom errors (admin role) ───────────────────────────────────── error NotAdminOrOwner(address caller); // onlyAdminOrOwner gate error AdminZero(); // setAdmin(address(0)) - // ─── Events (admin role) ──────────────────────────────────────────── - // - // Declared on the implementation rather than in IExitFeeController: - // that file is the cross-pragma interface consumed by product code and - // mirrored by the v0_4 variant, and the two must stay ABI-identical. - // The admin role is an owner-side surface that product code never - // touches, so the shared interface stays untouched. - - event AdminSet(address indexed admin); - // ─── Modifiers ────────────────────────────────────────────────────── - /// @dev The `admin` guardian OR the `Ownable2Step` owner. Gates only - /// the operational levers (`setExitFeeEnabled`, `setFeeReceiver`); - /// every other setter stays `onlyOwner`. + /// @dev The `Admin` guardian OR the `Ownable2Step` owner. Gates the + /// perimeter kill switch and — since the core + /// merge — the fee levers `setExitFeeEnabled` / + /// `setFeeReceiver`. All other setters are `onlyOwner`. modifier onlyAdminOrOwner() { if (msg.sender != admin && msg.sender != owner()) revert NotAdminOrOwner(msg.sender); _; @@ -217,7 +353,12 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable revert OwnershipCannotBeRenounced(); } - // ─── Admin: global state ──────────────────────────────────────────── + // NOTE the + // `_transferOwnership` chokepoint override (Admin != Owner enforced on + // the ownership side) was REMOVED together with `setAdmin`'s + // owner-equality check — admin == owner is a supported shape (the + // governance Safe holds both roles at launch). The queue's counterpart + // was removed in the same change. /// @notice Appoint (or rotate) the operational guardian checked by /// `onlyAdminOrOwner`. Owner-only. `address(0)` is rejected -- @@ -232,6 +373,8 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable emit AdminSet(newAdmin); } + // ─── Admin: global state ──────────────────────────────────────────── + /// @notice Flip the global kill switch. While `false`, every /// `quoteExitFee` returns `INACTIVE` and product hooks no-op. /// All other configuration is preserved across flips. @@ -256,6 +399,37 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable emit FeeReceiverSet(newReceiver); } + // ─── Admin: delay global state ─────────────── + + /// @notice Flip the DELAY perimeter kill switch. `onlyAdminOrOwner` + /// (the fee levers joined this gate in) — both + /// directions, for sub-minute incident response. Independent of + /// `exitFeeEnabled`: + /// disabling fees does NOT disable the perimeter and vice-versa. + /// Accepted residual: a rogue Admin can DISABLE the perimeter (a + /// protocol-wide fail-OPEN delay removal) — the deliberate tradeoff. + /// @param enabled Target state for the perimeter. + // aderyn-ignore-next-line(centralization-risk) + function setSecurityPerimeterEnabled(bool enabled) external onlyAdminOrOwner { + securityPerimeterEnabled = enabled; + emit SecurityPerimeterEnabledSet(enabled); + } + + /// @notice Set the single global delay applied whenever a delay is imposed. + /// Owner-only. The `>= queue.minimumDelaySeconds` floor + /// is a documented liveness invariant enforced PER-REQUEST in the + /// queue and by a deploy-script assertion — NOT a + /// cross-contract setter guard, so the controller never reads or + /// calls the queue (kill-switch queue-independence). A value + /// below the floor would self-brick every non-bypassed exit + /// (fail-closed) but can NEVER rush a request below the floor. + /// @param seconds_ Delay in seconds (uint32; 0 is allowed and disables the + /// delay for all non-bypassed exits, equivalent to a global bypass). + function setGlobalDelaySeconds(uint32 seconds_) external onlyOwner { + globalDelaySeconds = seconds_; + emit GlobalDelaySet(seconds_); + } + // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain @@ -435,6 +609,216 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable } } + // ─── Admin: delay bypass tiers ──────────────────── + // + // Mirrors the fee-tier setters but on `DelayBypassPolicy`. A + // `{active:true, bypass:true}` entry EXEMPTS the surface/subProduct/actor + // (d = 0, instant); a `{active:true, bypass:false}` entry FORCES the global + // delay (overriding a broader bypass). `{active:false}` falls through. The + // enumeration keys retain soft-retired entries; use the removers for hard + // removal. Precedence and the truth table live in `_resolveDelay` below. + + /// @notice Configure (or update) the surface-tier delay bypass for + /// `surfaceId`. Overwriting is idempotent. + function setSurfaceBypass(bytes32 surfaceId, IExitFeeController.DelayBypassPolicy calldata policy) + external + onlyOwner + { + _writeSurfaceBypass(surfaceId, policy); + } + + function _writeSurfaceBypass(bytes32 surfaceId, IExitFeeController.DelayBypassPolicy calldata policy) + internal + { + _surfaceBypass[surfaceId] = policy; + // Per-tier enumeration index. Idempotent: add returns + // false if already present. Retained on soft-retire (`active=false`). + // aderyn-ignore-next-line(unchecked-return) + _surfaceBypassKeys.add(surfaceId); + // ANY-TIER-TOUCHED master set: record the surfaceId so the + // inspector discovers it regardless of which tier configured it. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit SurfaceBypassSet(surfaceId, policy.active, policy.bypass); + } + + /// @notice Hard-remove a surface-tier delay bypass: clears the stored policy + /// and drops the surfaceId from the enumeration index. Idempotent -- + /// a remove for a surfaceId not currently in the index is a successful + /// no-op (no event, no revert). To temporarily disable while keeping it + /// visible in the inspector, use `setSurfaceBypass(id, {false, false})`. + function removeSurfaceBypass(bytes32 surfaceId) external onlyOwner { + if (_surfaceBypassKeys.remove(surfaceId)) { + delete _surfaceBypass[surfaceId]; + emit SurfaceBypassRemoved(surfaceId); + } + } + + /// @notice Configure (or update) a sub-product-tier delay bypass. Records + /// the address in the enumeration index. `subProduct` non-zero. + function setSubProductBypass( + bytes32 surfaceId, + address subProduct, + IExitFeeController.DelayBypassPolicy calldata policy + ) external onlyOwner { + _writeSubProductBypass(surfaceId, subProduct, policy); + } + + /// @notice Batch variant of `setSubProductBypass`. Reverts on length + /// mismatch or a zero address; a revert mid-batch undoes the batch. + function setSubProductBypasses( + bytes32 surfaceId, + address[] calldata subProducts, + IExitFeeController.DelayBypassPolicy[] calldata policies + ) external onlyOwner { + uint256 len = subProducts.length; + if (len != policies.length) revert LengthMismatch(); + for (uint256 i = 0; i < len; ++i) { + _writeSubProductBypass(surfaceId, subProducts[i], policies[i]); + } + } + + /// @notice Configure (or update) an actor-tier delay bypass (most specific; + /// evaluated on `effOrig`). `actor` non-zero. + function setActorBypass( + bytes32 surfaceId, + address actor, + IExitFeeController.DelayBypassPolicy calldata policy + ) external onlyOwner { + _writeActorBypass(surfaceId, actor, policy); + } + + /// @notice Batch variant of `setActorBypass`. Same revert semantics as + /// `setSubProductBypasses`. + function setActorBypasses( + bytes32 surfaceId, + address[] calldata actors, + IExitFeeController.DelayBypassPolicy[] calldata policies + ) external onlyOwner { + uint256 len = actors.length; + if (len != policies.length) revert LengthMismatch(); + for (uint256 i = 0; i < len; ++i) { + _writeActorBypass(surfaceId, actors[i], policies[i]); + } + } + + function _writeSubProductBypass( + bytes32 surfaceId, + address subProduct, + IExitFeeController.DelayBypassPolicy calldata policy + ) internal { + if (subProduct == address(0)) revert SubProductZero(); + _subProductBypass[surfaceId][subProduct] = policy; + // aderyn-ignore-next-line(unchecked-return) + _subProductBypassKeys[surfaceId].add(subProduct); + // ANY-TIER-TOUCHED master set: a sub-product-only bypass + // under an arbitrary surfaceId is otherwise undiscoverable — record it. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit SubProductBypassSet(surfaceId, subProduct, policy.active, policy.bypass); + } + + function _writeActorBypass( + bytes32 surfaceId, + address actor, + IExitFeeController.DelayBypassPolicy calldata policy + ) internal { + if (actor == address(0)) revert ActorZero(); + _actorBypass[surfaceId][actor] = policy; + // aderyn-ignore-next-line(unchecked-return) + _actorBypassKeys[surfaceId].add(actor); + // ANY-TIER-TOUCHED master set: the actor tier is the MOST + // COMMON exemption shape — an actor-only bypass with no prior + // setSurfaceBypass MUST still surface the id to the inspector. + // aderyn-ignore-next-line(unchecked-return) + _bypassSurfaceIds.add(surfaceId); + emit ActorBypassSet(surfaceId, actor, policy.active, policy.bypass); + } + + /// @notice Hard-remove a sub-product-tier delay bypass: clears the stored + /// policy and drops it from the enumeration index. Idempotent. + function removeSubProductBypass(bytes32 surfaceId, address subProduct) external onlyOwner { + _removeSubProductBypass(surfaceId, subProduct); + } + + /// @notice Batch variant of `removeSubProductBypass`. + function removeSubProductBypasses(bytes32 surfaceId, address[] calldata subProducts) external onlyOwner { + uint256 len = subProducts.length; + for (uint256 i = 0; i < len; ++i) { + _removeSubProductBypass(surfaceId, subProducts[i]); + } + } + + /// @notice Hard-remove an actor-tier delay bypass. Idempotent. + function removeActorBypass(bytes32 surfaceId, address actor) external onlyOwner { + _removeActorBypass(surfaceId, actor); + } + + /// @notice Batch variant of `removeActorBypass`. + function removeActorBypasses(bytes32 surfaceId, address[] calldata actors) external onlyOwner { + uint256 len = actors.length; + for (uint256 i = 0; i < len; ++i) { + _removeActorBypass(surfaceId, actors[i]); + } + } + + function _removeSubProductBypass(bytes32 surfaceId, address subProduct) internal { + if (subProduct == address(0)) revert SubProductZero(); + if (_subProductBypassKeys[surfaceId].remove(subProduct)) { + delete _subProductBypass[surfaceId][subProduct]; + emit SubProductBypassRemoved(surfaceId, subProduct); + } + } + + function _removeActorBypass(bytes32 surfaceId, address actor) internal { + if (actor == address(0)) revert ActorZero(); + if (_actorBypassKeys[surfaceId].remove(actor)) { + delete _actorBypass[surfaceId][actor]; + emit ActorBypassRemoved(surfaceId, actor); + } + } + + // ─── Admin: surface-scoped passthrough registry ─────────────── + + /// @notice Register/deregister a surface-scoped passthrough actor. + /// A passthrough registered for `surfaceId` normalizes to the + /// `receiver` in `effectiveActor` / `quoteExitDelayFor`. Owner-only + /// and — because `bypass=true` is equivalent to zero delay and a + /// passthrough rewrites the block key — as security-critical as the + /// source-registry. NEVER collapses identities globally: the + /// wrapper is registered ONLY under `SURFACE_LENDING_LENDER_WITHDRAW`, + /// so margin/Zero keep their raw identities. + /// @param surfaceId Operation-kind identifier. + /// @param a Passthrough contract (e.g. the RBTCWrapperProxy). + /// @param isPassthrough True to register, false to deregister. + // aderyn-ignore-next-line(centralization-risk) + function setPassthroughActor(bytes32 surfaceId, address a, bool isPassthrough) external onlyOwner { + if (a == address(0)) revert ActorZero(); + _passthroughActor[surfaceId][a] = isPassthrough; + // Keep the enumeration index exact-to-live: a passthrough is + // a boolean membership with no soft-retire state, so add on register and + // drop on deregister. add/remove return values are intentionally unchecked + // (idempotent — a repeat register or a deregister of an absent entry is a + // successful no-op that still emits, matching the setter's overwrite + // semantics). + if (isPassthrough) { + // aderyn-ignore-next-line(unchecked-return) + _passthroughKeys[surfaceId].add(a); + // ANY-TIER-TOUCHED master set: record the surfaceId on + // register so a passthrough-only surface (no bypass entry, not a named + // fee surface) is still a probe point for the inspector. Surface-level + // retention: the id stays even after every passthrough under it is + // deregistered — the per-surface `_passthroughKeys` set going empty is + // the live signal, and keeping the id guarantees the probe point. + // aderyn-ignore-next-line(unchecked-return) + _passthroughSurfaceIds.add(surfaceId); + } else { + // aderyn-ignore-next-line(unchecked-return) + _passthroughKeys[surfaceId].remove(a); + } + emit PassthroughActorSet(surfaceId, a, isPassthrough); + } + // ─── Policy views ─────────────────────────────────────────────────── /// @notice Surface tier (gate + default rate) for `surfaceId`. Returns @@ -488,6 +872,103 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable return _actorKeys[surfaceId].values(); } + // ─── Delay views ────────────────────────────────── + + /// @notice Surface-tier delay bypass for `surfaceId`. Zero-initialised if + /// never configured (`{active:false, bypass:false}`). + function surfaceBypass(bytes32 surfaceId) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _surfaceBypass[surfaceId]; + } + + /// @notice Sub-product-tier delay bypass for `(surfaceId, subProduct)`. + function subProductBypass(bytes32 surfaceId, address subProduct) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _subProductBypass[surfaceId][subProduct]; + } + + /// @notice Actor-tier delay bypass for `(surfaceId, actor)`. + function actorBypass(bytes32 surfaceId, address actor) + external + view + returns (IExitFeeController.DelayBypassPolicy memory) + { + return _actorBypass[surfaceId][actor]; + } + + /// @notice Every surfaceId configured in the surface-tier delay-bypass index. + /// Takes NO argument — surface bypasses are keyed by + /// `surfaceId` alone, NOT scoped by a parent surface. Entries are not + /// removed when a bypass is set inactive; pair each id with + /// `surfaceBypass(id)` to see the live state, or use + /// `removeSurfaceBypass(id)` for hard removal. Intended for + /// `InspectController` / monitoring so there is no unenumerable + /// zero-delay state. + /// @return Snapshot of every configured surface-bypass surfaceId. + function surfaceBypassKeys() external view returns (bytes32[] memory) { + return _surfaceBypassKeys.values(); + } + + /// @notice Every sub-product configured under `surfaceId` in the delay + /// bypass index. Same retention semantics as `subProductKeys`. + function subProductBypassKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _subProductBypassKeys[surfaceId].values(); + } + + /// @notice Every actor configured under `surfaceId` in the delay bypass + /// index. Same retention semantics as `actorKeys`. + function actorBypassKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _actorBypassKeys[surfaceId].values(); + } + + /// @notice ANY-TIER-TOUCHED master set: EVERY surfaceId that has a bypass + /// entry at ANY tier — surface, sub-product, OR actor. + /// This is the discovery driver for `InspectController`: unlike + /// `surfaceBypassKeys()` (surface-tier writes only), this records the + /// surfaceId from `setSurfaceBypass`, `setSubProductBypass`, AND + /// `setActorBypass`, so a surface carrying ONLY a sub-product- or + /// actor-tier bypass (the most common exemption shape) is never missed. + /// Retention-only: entries are never dropped, so a `removeSurfaceBypass` + /// while sub/actor entries remain live keeps the id in discovery — the + /// inspector re-probes every tier per id and shows the live state. + /// @return Snapshot of every surfaceId touched by any bypass tier. + function bypassSurfaceIds() external view returns (bytes32[] memory) { + return _bypassSurfaceIds.values(); + } + + /// @notice ANY-TIER-TOUCHED master set for the passthrough registry: every + /// surfaceId under which a passthrough has been registered. + /// Drives `InspectController`'s passthrough dump so a + /// passthrough-only surface (no bypass entry, not a named fee surface) + /// is still enumerable. Surface-level retention: an id stays recorded + /// even after every passthrough under it is deregistered. + /// @return Snapshot of every surfaceId touched by the passthrough registry. + function passthroughSurfaceIds() external view returns (bytes32[] memory) { + return _passthroughSurfaceIds.values(); + } + + /// @notice Every passthrough address registered under `surfaceId`. + /// Exact-to-live: an address enters on `setPassthroughActor(.., true)` + /// and is dropped on `setPassthroughActor(.., false)`. Gives the + /// security-critical passthrough registry the same on-chain + /// enumerability as the bypass tiers (no events-only blind spot). + /// @param surfaceId See `setSurfacePolicy`. + /// @return Snapshot of every live passthrough address under `surfaceId`. + function passthroughKeys(bytes32 surfaceId) external view returns (address[] memory) { + return _passthroughKeys[surfaceId].values(); + } + + /// @notice Whether `a` is a surface-scoped passthrough for `surfaceId`. + function passthroughActor(bytes32 surfaceId, address a) external view returns (bool) { + return _passthroughActor[surfaceId][a]; + } + // ─── Quote ────────────────────────────────────────────────────────── /// @inheritdoc IExitFeeController @@ -578,4 +1059,87 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // Surface fallback (always active here, since we checked above). return surface; } + + // ─── Delay quote + resolution ───────────────────── + + /// @inheritdoc IExitFeeController + function effectiveActor(bytes32 surfaceId, address raw, address receiver) public view returns (address) { + // A passthrough registered FOR THIS SURFACE resolves to the receiver; + // otherwise identity. Never collapses identities globally — a + // surface with no passthrough entry returns `raw` unchanged, so margin + // and Zero keep their real originator/owner. + return _passthroughActor[surfaceId][raw] ? receiver : raw; + } + + /// @inheritdoc IExitFeeController + function quoteExitDelayFor( + address rawOriginator, + address owner_, + address receiver, + bytes32 surfaceId, + address subProduct + ) external view returns (uint32 d, address effOrig, address effOwner) { + // KILL-SWITCH SHORT-CIRCUIT FIRST: a disabled perimeter pays + // direct WITHOUT consulting the passthrough registry or the queue — the + // liveness escape. Returns RAW identities; the hook must ignore them and + // pay direct whenever d == 0, so the raw identities never record. + if (!securityPerimeterEnabled) { + return (0, rawOriginator, owner_); + } + + // Resolve the surface-scoped effective identities, then quote on + // effOrig, so the quote and the record share ONE identity (Finding 2). + effOrig = effectiveActor(surfaceId, rawOriginator, receiver); + effOwner = effectiveActor(surfaceId, owner_, receiver); + d = _resolveDelay(surfaceId, subProduct, effOrig); + } + + /// @inheritdoc IExitFeeController + function quoteExitDelay(bytes32 surfaceId, address subProduct, address effectiveActor_) + external + view + returns (uint32) + { + // Handles the disabled-perimeter case identically (returns 0 when the + // perimeter is off) so an off-chain caller of the inner view never gets + // a non-zero delay while the perimeter is disabled. Callers pass an + // ALREADY-effective actor (never a raw wrapper). + if (!securityPerimeterEnabled) return 0; + return _resolveDelay(surfaceId, subProduct, effectiveActor_); + } + + /// @dev The 3-tier delay resolver (truth table). Evaluated on + /// the EFFECTIVE originator. Precedence mirrors the fee resolver — + /// actor > subProduct > surface, most-specific-*active*-wins — but the + /// SOLE gate is `securityPerimeterEnabled` (checked by the callers + /// above): the delay resolver is INDEPENDENT of the fee + /// `surfacePolicy.active` and `exitFeeEnabled`. A `{active:false}` tier + /// falls through; an `{active:true}` tier decides — `bypass ? 0 : + /// globalDelaySeconds`. With no active bypass tier the default is + /// `globalDelaySeconds` (delayed). An active `{bypass:false}` tier at a + /// more-specific level overrides a broader bypass. + function _resolveDelay(bytes32 surfaceId, address subProduct, address effOrig) + internal + view + returns (uint32) + { + // Actor tier (most specific). No global actor bypass — always keyed + // [surfaceId][effOrig]. + IExitFeeController.DelayBypassPolicy memory a = _actorBypass[surfaceId][effOrig]; + if (a.active) return a.bypass ? 0 : globalDelaySeconds; + + // Sub-product tier. address(0) means "no per-instance dimension" (e.g. + // Zero) — skip the lookup so a sibling sub-product cannot leak in. + if (subProduct != address(0)) { + IExitFeeController.DelayBypassPolicy memory s = _subProductBypass[surfaceId][subProduct]; + if (s.active) return s.bypass ? 0 : globalDelaySeconds; + } + + // Surface tier. + IExitFeeController.DelayBypassPolicy memory f = _surfaceBypass[surfaceId]; + if (f.active) return f.bypass ? 0 : globalDelaySeconds; + + // No active bypass tier: default is the global delay (delayed). + return globalDelaySeconds; + } } diff --git a/src/interfaces/IExitDelayQueue.sol b/src/interfaces/IExitDelayQueue.sol new file mode 100644 index 0000000..7feaf9b --- /dev/null +++ b/src/interfaces/IExitDelayQueue.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/// @title IExitDelayQueue +/// @notice External ABI + type/event/error catalog for `ExitDelayQueue`, the +/// per-request escrow that holds the *user* leg of an exit for a +/// configurable delay so a detected theft can be blocked (frozen or +/// blacklisted) and routed to recovery before the funds leave. +/// +/// Authoritative build spec: +/// This interface mirrors the +/// complete function catalog and the event/error catalog; the +/// types mirror +/// +/// Types (enums/structs) are declared here so cross-pragma callers +/// and off-chain tooling share one source of truth. The queue itself +/// is 0.8.20 UUPS; the four `record*` ingress fns are consumed from +/// 0.5.x / 0.6.x product hosts via a minimal interface stub. +interface IExitDelayQueue { + // ─── Types ─────────────────────────────────────────────────── + + /// @notice Per-request lifecycle. `None` is the zero value (never stored + /// for a live id); the three terminal states are mutually + /// exclusive and a request leaves `Queued` at most once. + enum ExitStatus { + None, // 0 — never recorded + Queued, // 1 — escrowed, awaiting execute / recovery + Executed, // 2 — paid to receiver (terminal) + ResolvedToProtocol, // 3 — Leg-2 recovery-away (terminal) + ResolvedBySIP // 4 — Leg-3 DAO catch-all (terminal) + + } + + /// @notice Per-address block state. `Frozen` = temporary (investigating); + /// `Blacklisted` = confirmed hack. Execution treats both as + /// "blocked"; recovery-away distinguishes them. + enum BlockState { + None, // 0 + Frozen, // 1 — temporary, cleared by unfreeze + Blacklisted // 2 — confirmed, cleared only by unblacklist + + } + + /// @notice An immutable exit request. Every field except `status` is + /// frozen at record time. Packed into 7 words. + struct ExitRequest { + // word 1 (128 + 64 + 64 = 256 bits): + uint128 amount; // narrowed from the uint256 ColFee amount at record + uint64 createdAt; // audit/analytics; emitted in ExitQueued + uint64 unlockAt; // COMPUTED by the queue = createdAt + delaySeconds + // words 2-5: + address originator; // withdrawal caller (effective, post-normalization) — block key + executor + address owner; // position owner — MANDATORY block key + executor + address receiver; // immutable payout destination — block key iff freezeReceiver; NOT an executor + address token; // address(0) = native RBTC + // word 6: + bytes32 surfaceId; // provenance: recovery-route key + // word 7 (160 + 8 + 8 = 176 bits): + address subProduct; // provenance: iToken / converter / address(0) + ExitStatus status; // uint8 + bool unwrapOnDelivery; // Option B: queue holds WRBTC, executeExit unwraps → native RBTC + } + + /// @notice A pre-approved Leg-2 recovery route. `routeId` is + /// `keccak256(abi.encode(surfaceId, subProduct, token, destination))`. + struct RecoveryRoute { + bool active; + bytes32 surfaceId; + address subProduct; + address token; + address destination; + bool topUpPool; // 2a: plain top-up of the originating pool (destination == subProduct) + } + + // ─── Events ────────────────────────────────────────────────── + + event ExitQueued( + uint256 indexed id, + address indexed originator, + address indexed owner, + address receiver, + address token, + uint128 amount, + uint64 unlockAt, + bytes32 surfaceId, + address subProduct + ); + event ExitExecuted(uint256 indexed id, address indexed receiver, address token, uint128 amount); + event ExitResolvedToProtocol( + uint256 indexed id, bytes32 indexed routeId, address destination, uint128 amount + ); + event ExitResolvedBySIP(uint256 indexed id, address indexed destination, uint128 amount); + event AccountBlocked( + address indexed account, BlockState state, uint256 indexed triggerRequestId, bytes32 reasonHash + ); + event AccountUnblocked(address indexed account, BlockState fromState); + event RecoveryRouteSet( + bytes32 indexed routeId, + bytes32 surfaceId, + address subProduct, + address token, + address destination, + bool topUpPool + ); + event RecoveryRouteRemoved(bytes32 indexed routeId); + event AllowedSourceSet(address indexed source, bool allowed); + event TopUpFeasibleSet(bytes32 indexed surfaceId, bool feasible); + event MinimumDelaySet(uint32 seconds_); + event SecurityPerimeterPausedSet(bool paused); + event NativePusherSet(address indexed pusher); + event SurplusSwept(address indexed token, address indexed to, uint256 amount); // + + // ─── Custom errors ─────────────────────────────────────────── + + error UnregisteredSource(address caller); // onlyAllowedSource — DISTINCT record-path halt selector + error ActorBlocked(address actor, BlockState state); // execution-gate revert (event: AccountBlocked) + error NotExecutor(address caller); // msg.sender ∉ {originator, owner} + error NotUnlocked(uint256 id, uint64 unlockAt); + error QueuePaused(); + error AlreadyTerminal(uint256 id); // status != Queued at a transition (also duplicate-batch-id) + error UnknownRequest(uint256 id); + error DelayBelowFloor(uint32 delay, uint32 floor); + error AmountTooLarge(uint256 amount); // uint256→uint128 narrowing guard + error AmountMismatch(uint256 msgValue, uint256 amount); // native value-carrying + error ReceivedAmountMismatch(address token, uint256 have, uint256 want); // pull / measured-delta proof + error ZeroAmount(); + error RouteInactive(bytes32 routeId); + error RouteProvenanceMismatch(uint256 id, bytes32 routeId); + error TopUpInfeasibleSurface(bytes32 surfaceId); // setRecoveryRoute topUpPool guard + error SourceNotBlacklisted(address src); // Leg-2 OR-predicate not satisfied + error NotBlacklisted(address a); // unblacklist on a non-Blacklisted address + error NotFrozen(address a); // unfreeze on a non-Frozen address + error NotResolvableBySIP(uint256 id); // Leg-3 bounded predicate not satisfied + error UnwrapNonWrbtc(); // unwrapOnDelivery set on a non-WRBTC token (guard) + error InvalidAltReceiver(address altReceiver); // recoverStuckExit altReceiver ∈ {0,this,token,wrbtc} + error SelfOnly(); // payoutExternal trampoline is self-call-only + error ZeroAddress(); + error EmptyIds(); + error SweepToZero(); + error SolvencyViolated(); // post-sweep balance < totalEscrowed + + // ─── Ingress ────────────────────────────────────────── + + /// @dev CALLER-SIDE NARROWING PRECONDITION. Every + /// `record*` takes `amount` as a **`uint128`**, deliberately NOT widened + /// to `uint256`. The ColFee hook computes the user leg as a `uint256` and + /// MUST narrow it (`uint128(userAmount)`) at the call site; that narrowing + /// is the caller's responsibility and MUST be preceded by the caller's own + /// `require(userAmount <= type(uint128).max)` (`AmountTooLarge`) so a value + /// that would silently truncate is rejected UPSTREAM, before any escrow + /// accounting. The queue itself CANNOT re-assert this on an + /// already-`uint128` argument — the `AmountTooLarge` error is declared for + /// that CALLER-side hook boundary, not re-checked here. Keeping + /// the ABI at `uint128` also packs `amount` into `ExitRequest` word 1 + /// — widening would cost a whole extra storage word per request. + + function recordERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrapOnDelivery + ) external returns (uint256 id); + + function recordReceivedERC20Exit( + address token, + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256 id); + + function recordNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable returns (uint256 id); + + function recordReceivedNativeExit( + uint128 amount, + uint32 delaySeconds, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256 id); + + // ─── Execution ─────────────────────────────────────────────── + + function executeExit(uint256 requestId) external; + + function executeExits(uint256[] calldata ids) external; + + /// @notice Verify-by-attempting stuck-exit recovery. Callable ONLY by the + /// frozen-metadata `{originator, owner}` set (same as `executeExit`; the + /// receiver is NEVER an executor). Requires the request Queued, unlocked, + /// and the queue not paused. + /// + /// Attempts the STORED-receiver payout FIRST; pays `altReceiver` ONLY if + /// the stored-receiver payout genuinely bounces — so a HEALTHY exit is + /// never redirected (no arbitrary redirect; Model-B stays rejected) + /// and there is NO stored failure flag. If `altReceiver` also fails, the + /// whole call reverts (funds stay Queued). + /// + /// Block gate covers ALL FOUR actors — `{originator, owner, STORED + /// receiver, altReceiver}`: a blocked/hacked original receiver refuses + /// recovery entirely (→ Leg-3), preserving the blacklist-trap and + /// the / dead-end. `altReceiver` is guarded: reverts if it is + /// `0`, this contract, the request token, or WRBTC. The stored request is + /// NEVER re-targeted (`altReceiver` is a payout-time destination only), so + /// request immutability and the block gate still hold. + function recoverStuckExit(uint256 id, address altReceiver) external; + + // ─── Block model ───────────────────────────────────────────── + + function freezeFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) external; + function blacklistFromRequest(uint256 requestId, bool freezeReceiver, bytes32 reasonHash) external; + + // Batch by-request-id — whole-batch atomic (one bad + // id reverts all, like executeExits); last-write-wins trigger/reason per + function freezeFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external; + function blacklistFromRequest(uint256[] calldata requestIds, bool freezeReceiver, bytes32 reasonHash) + external; + + function freeze(address a) external; + function blacklist(address a) external; + function unfreeze(address a) external; + function unblacklist(address a) external; + + // Batch by-address: each reverts `EmptyIds()` on + // an empty array, for API consistency with the by-id batch variants + // (`executeExits` / batch `freezeFromRequest` / `resolveToProtocol` / + // `resolveBySIP`) — an empty batch is a caller mistake, never a silent no-op. + function freeze(address[] calldata a) external; + function blacklist(address[] calldata a) external; + function unfreeze(address[] calldata a) external; + function unblacklist(address[] calldata a) external; + + // ─── Pause ─────────────────────────────────────────────────── + + function setSecurityPerimeterPaused(bool p) external; + + // ─── Recovery ──────────────────────────────────────────────── + + function resolveToProtocol(uint256[] calldata ids, bytes32 routeId) external; + function resolveBySIP(uint256[] calldata ids, address destination) external; + + function setRecoveryRoute(RecoveryRoute calldata route) external returns (bytes32 routeId); + function removeRecoveryRoute(bytes32 routeId) external; + function setTopUpFeasible(bytes32 surfaceId, bool feasible) external; + + // ─── Config ────────────────────────────────────────── + + function addAllowedSource(address src) external; + function removeAllowedSource(address src) external; + function setNativePusher(address pusher) external; + function setMinimumDelaySeconds(uint32 s) external; + function sweepSurplus(address token, address to) external; + + // ─── Views ─────────────────────────────────────────────────── + + function getRequest(uint256 id) external view returns (ExitRequest memory); + function getActive(address party, uint256 cursor, uint256 n) + external + view + returns (uint256[] memory ids, uint256 nextCursor); + function blockStateOf(address a) external view returns (BlockState); + + /// @notice Paginate the blocked set (Frozen ∪ Blacklisted). + /// @param offset First index into the blocked set to return. + /// @param limit Requested page size; clamped to `MAX_GET_ACTIVE_PAGE` (500). + /// @return page The clamped slice `[offset, offset + page.length)` of the + /// blocked set (empty when `offset >= total` or `limit == 0`). + /// @return total The FULL blocked-set size (EnumerableSet length), independent + /// of `offset`/`limit` — so a caller/monitor knows the whole + /// range ("showing offset..offset+page.length of total") and + /// never silently undercounts past the 500-entry page cap. + function blockedAccounts(uint256 offset, uint256 limit) + external + view + returns (address[] memory page, uint256 total); + function blockTrigger(address a) external view returns (uint256); + + function totalEscrowed(address token) external view returns (uint256); + function getRecoveryRoute(bytes32 routeId) external view returns (RecoveryRoute memory); + function allowedSources() external view returns (address[] memory); + + /// @notice Max page size for the paginated `getActive` / `blockedAccounts` + /// views. Public constant, so paging is + /// self-describing on-chain (500). + function MAX_GET_ACTIVE_PAGE() external view returns (uint256); +} diff --git a/src/interfaces/IExitDelayQueueHost.sol b/src/interfaces/IExitDelayQueueHost.sol new file mode 100644 index 0000000..2cff832 --- /dev/null +++ b/src/interfaces/IExitDelayQueueHost.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +/// @title IExitDelayQueueHost — the queue-pointer surface a product host exposes +/// @notice Minimal interface for the `setExitDelayQueue` pointer. +/// Each product storage host — the `sovrynProtocol` singleton (lending + +/// loan/margin) and the Zero `BorrowerOperations` proxy — holds its OWN +/// queue address in a NEW EIP-1967-style unstructured slot +/// `keccak256("sovryn.exitDelayQueue") - 1`, set by `setExitDelayQueue` +/// (gated by the SAME host admin as `setExitFeeController`, Owner/Timelock, +/// and rotatable) and read back via `exitDelayQueue()`. +/// +/// @dev The host CONTRACTS live in the product repos (Sovryn-smart-contracts-colfee +/// / zero-contracts-colfee) on the 0.5.x / 0.6.x pragmas; this 0.8.20 stub +/// exists ONLY so the colfee deploy/wire script (05) can call the pointer +/// setter and read it back over the cross-pragma ABI when the host addresses +/// are supplied. It intentionally declares nothing else — the queue itself +/// is never a host and never implements this. +interface IExitDelayQueueHost { + /// @notice Point this host's exit-delay reroute at `queue` (wire/rotate). + /// Owner/Timelock-gated on the host; a no-op in colfee (hosts are external). + function setExitDelayQueue(address queue) external; + + /// @notice The host's current queue pointer (0 = reroute unwired ⇒ direct pay). + function exitDelayQueue() external view returns (address); +} diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index 05c142b..b382e3a 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -45,6 +45,22 @@ interface IExitFeeController { uint16 rateBps; } + /// @notice A single delay-bypass entry (the DELAY feature). Lives + /// at each of the three bypass tiers (actor → sub-product → surface), + /// mirroring `RatePolicy`'s shape. + /// + /// `active == false` ⇒ the tier is not configured; resolution falls + /// through to the next-broader tier. `active == true` ⇒ this tier + /// decides: `bypass == true` exempts the exit (`d = 0`, instant), + /// `bypass == false` FORCES the global delay (overriding a broader + /// bypass). It is a bypass/exemption toggle only — there is NO + /// per-instance delay *duration* (a single `globalDelaySeconds` + /// applies whenever a delay is imposed). + struct DelayBypassPolicy { + bool active; + bool bypass; + } + /// @notice Quote returned by `quoteExitFee`. `reason` carries the precise /// off-state code; `active` is the resolved policy state (true iff /// a RatePolicy.active entry was used and reason ∈ {NONE}). @@ -69,6 +85,21 @@ interface IExitFeeController { event SubProductPolicyRemoved(bytes32 indexed surfaceId, address indexed subProduct); event ActorPolicyRemoved(bytes32 indexed surfaceId, address indexed actor); + // ─── Events (delay extension) ───────────────────────────────────── + + event AdminSet(address indexed admin); + event SecurityPerimeterEnabledSet(bool enabled); + event GlobalDelaySet(uint32 seconds_); + event SurfaceBypassSet(bytes32 indexed surfaceId, bool active, bool bypass); + event SurfaceBypassRemoved(bytes32 indexed surfaceId); + event SubProductBypassSet( + bytes32 indexed surfaceId, address indexed subProduct, bool active, bool bypass + ); + event ActorBypassSet(bytes32 indexed surfaceId, address indexed actor, bool active, bool bypass); + event SubProductBypassRemoved(bytes32 indexed surfaceId, address indexed subProduct); + event ActorBypassRemoved(bytes32 indexed surfaceId, address indexed actor); + event PassthroughActorSet(bytes32 indexed surfaceId, address indexed actor, bool isPassthrough); + // ─── Quote ──────────────────────────────────────────────────────────── /// @notice Resolve the fee policy for `(surfaceId, subProduct, actor)` and @@ -81,6 +112,55 @@ interface IExitFeeController { view returns (ExitFeeQuote memory); + // ─── Delay quote (hook entry) ───────────────────────────────────── + + /// @notice The delay hook's SINGLE hot-path entry. Short-circuits + /// the kill switch FIRST: when `securityPerimeterEnabled == false` it + /// returns `(0, rawOriginator, owner)` WITHOUT consulting the + /// passthrough registry or the escrow queue (the liveness escape). + /// Otherwise it resolves the surface-scoped effective actors + /// (`effOrig`/`effOwner`) — a registered passthrough for `surfaceId` + /// resolves to `receiver` — quotes the delay on `effOrig`, and + /// returns all three so the quote and the record share ONE identity + /// (Finding 2). The hook MUST ignore `effOrig`/`effOwner` and pay + /// direct whenever `d == 0`. + /// @param rawOriginator The withdrawal caller (pre-normalization). + /// @param owner The position owner (iToken holder / borrower / trove). + /// @param receiver The immutable payout destination. + /// @param surfaceId Operation-kind identifier (see fee tiers). + /// @param subProduct Per-instance address (iToken / converter / 0). + /// @return d Delay seconds to escrow for (0 ⇒ off / inactive / bypassed). + /// @return effOrig Effective originator (raw or passthrough→receiver). + /// @return effOwner Effective owner (raw or passthrough→receiver). + function quoteExitDelayFor( + address rawOriginator, + address owner, + address receiver, + bytes32 surfaceId, + address subProduct + ) external view returns (uint32 d, address effOrig, address effOwner); + + /// @notice Inner per-actor delay view (off / inactive / bypass ⇒ 0, else + /// `globalDelaySeconds`), evaluated on an ALREADY-effective actor. + /// Handles the disabled-perimeter case identically (returns 0 when + /// the perimeter is off). Hot-path callers use `quoteExitDelayFor`; + /// this is for off-chain quoting and the inner resolver. + /// @param surfaceId Operation-kind identifier. + /// @param subProduct Per-instance address (iToken / converter / 0). + /// @param effectiveActor The already-normalized actor (never a raw wrapper). + /// @return The delay seconds resolved by the 3-tier bypass resolver. + function quoteExitDelay(bytes32 surfaceId, address subProduct, address effectiveActor) + external + view + returns (uint32); + + /// @notice Resolve a surface-scoped passthrough: a passthrough registered + /// for `surfaceId` resolves `raw` to `receiver`, else identity. + function effectiveActor(bytes32 surfaceId, address raw, address receiver) + external + view + returns (address); + // ─── State views ────────────────────────────────────────────────────── function exitFeeEnabled() external view returns (bool); @@ -94,8 +174,62 @@ interface IExitFeeController { function subProductKeys(bytes32 surfaceId) external view returns (address[] memory); function actorKeys(bytes32 surfaceId) external view returns (address[] memory); + // ─── State views (delay extension) ────────────────────────────────────── + + function admin() external view returns (address); + function securityPerimeterEnabled() external view returns (bool); + function globalDelaySeconds() external view returns (uint32); + function surfaceBypass(bytes32 surfaceId) external view returns (DelayBypassPolicy memory); + function subProductBypass(bytes32 surfaceId, address subProduct) + external + view + returns (DelayBypassPolicy memory); + function actorBypass(bytes32 surfaceId, address actor) external view returns (DelayBypassPolicy memory); + + /// @notice Every surfaceId ever configured in the surface-tier delay-bypass + /// index. NOTE: this getter takes NO argument — surface + /// bypasses are keyed by `surfaceId` alone and are NOT scoped by a + /// parent surface. Backed by an `EnumerableSet.Bytes32Set` so + /// `InspectController` / monitoring can dump every surface bypass with + /// no unenumerable zero-delay state. Same soft-retire retention as the + /// other key-sets: entries persist on `{active:false}`; use + /// `removeSurfaceBypass` for hard removal. + function surfaceBypassKeys() external view returns (bytes32[] memory); + function subProductBypassKeys(bytes32 surfaceId) external view returns (address[] memory); + function actorBypassKeys(bytes32 surfaceId) external view returns (address[] memory); + + /// @notice ANY-TIER-TOUCHED master set: every surfaceId that + /// carries a delay-bypass entry at ANY tier — surface, sub-product, OR + /// actor. Recorded from all three writers, so a surface with ONLY a + /// sub-product- or actor-tier bypass (the most common exemption shape) + /// is enumerable even though it was never passed to + /// `setSurfaceBypass`. This is the discovery driver `InspectController` + /// uses so NO zero-delay config under an arbitrary surfaceId is + /// invisible. Retention-only (entries never dropped). + function bypassSurfaceIds() external view returns (bytes32[] memory); + + /// @notice ANY-TIER-TOUCHED master set for the passthrough registry: + /// every surfaceId under which a passthrough has been + /// registered. Lets `InspectController` discover a passthrough-only + /// surface (no bypass entry, not a named fee surface). Surface-level + /// retention (the id stays after every passthrough under it is dropped). + function passthroughSurfaceIds() external view returns (bytes32[] memory); + + /// @notice Every passthrough address ever registered under `surfaceId`. + /// Backed by an `EnumerableSet.AddressSet` so the + /// surface-scoped passthrough registry — as security-critical as the + /// bypass tiers — has no events-only blind spot. Entries are dropped + /// from the index when deregistered (`setPassthroughActor(.., false)`). + function passthroughKeys(bytes32 surfaceId) external view returns (address[] memory); + + function passthroughActor(bytes32 surfaceId, address a) external view returns (bool); + // ─── Admin ──────────────────────────────────────────────────────────── + /// @notice `onlyAdminOrOwner` since the core merge: + /// the fee kill switch and receiver re-point are operational + /// levers shared with the Admin guardian. Every other setter in + /// this section is Owner-only. function setExitFeeEnabled(bool enabled) external; function setFeeReceiver(address newReceiver) external; function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external; @@ -113,4 +247,59 @@ interface IExitFeeController { function removeSubProductPolicies(bytes32 surfaceId, address[] calldata subProducts) external; function removeActorPolicy(bytes32 surfaceId, address actor) external; function removeActorPolicies(bytes32 surfaceId, address[] calldata actors) external; + + // ─── Admin (delay extension) ──────────────────────────────────────────── + + /// @notice Rotate the fast operational guardian (`Admin`). Owner-only. + /// MAY equal the Owner -- nothing requires the two to be distinct. + /// The only delay principal on the controller; there is no OZ + /// AccessControl role. + function setAdmin(address newAdmin) external; + + /// @notice Flip the global delay kill switch. `onlyAdminOrOwner` in both + /// directions, for sub-minute incident response. Independent of + /// `exitFeeEnabled`. + function setSecurityPerimeterEnabled(bool enabled) external; + + /// @notice Set the single global delay applied whenever a delay is imposed. + /// Owner-only. The `>= minimumDelaySeconds` floor is a liveness + /// invariant enforced PER-REQUEST in the queue, not here -- the + /// controller never reads or calls the queue. + function setGlobalDelaySeconds(uint32 seconds_) external; + + function setSurfaceBypass(bytes32 surfaceId, DelayBypassPolicy calldata policy) external; + + /// @notice Hard-remove a surface-tier delay bypass: clears the stored policy + /// and drops the surfaceId from the enumeration index. Idempotent. + function removeSurfaceBypass(bytes32 surfaceId) external; + + function setSubProductBypass(bytes32 surfaceId, address subProduct, DelayBypassPolicy calldata policy) + external; + + function setSubProductBypasses( + bytes32 surfaceId, + address[] calldata subProducts, + DelayBypassPolicy[] calldata policies + ) external; + + function setActorBypass(bytes32 surfaceId, address actor, DelayBypassPolicy calldata policy) external; + + function setActorBypasses( + bytes32 surfaceId, + address[] calldata actors, + DelayBypassPolicy[] calldata policies + ) external; + + function removeSubProductBypass(bytes32 surfaceId, address subProduct) external; + + function removeSubProductBypasses(bytes32 surfaceId, address[] calldata subProducts) external; + + function removeActorBypass(bytes32 surfaceId, address actor) external; + + function removeActorBypasses(bytes32 surfaceId, address[] calldata actors) external; + + /// @notice Register/deregister a surface-scoped passthrough actor. A + /// passthrough registered for `surfaceId` normalizes to `receiver` + /// in `effectiveActor` / `quoteExitDelayFor`. Owner-only. + function setPassthroughActor(bytes32 surfaceId, address a, bool isPassthrough) external; } diff --git a/test/echidna/EchidnaExitDelayQueue.sol b/test/echidna/EchidnaExitDelayQueue.sol new file mode 100644 index 0000000..3942edc --- /dev/null +++ b/test/echidna/EchidnaExitDelayQueue.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +contract EchMockERC20 is ERC20 { + constructor() ERC20("Ech", "ECH") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract EchWRBTC { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @dev Self-contained Echidna harness (property mode, no cheatcodes). +/// The harness is contract owner + allowed source + native pusher and is +/// itself one of the request parties so it can execute. Ghost accounting +/// mirrors test/invariant/ExitDelayQueueHandler.sol: conservation +/// (/ solvency), id monotonicity, and single terminal +/// transition. +contract EchidnaExitDelayQueue { + ExitDelayQueue internal queue; + EchMockERC20 internal token; + EchWRBTC internal wrbtc; + + address internal constant ADMIN = address(0xAD01); + uint32 internal constant MIN_DELAY = 1 hours; + + address[3] internal actors; + + // ghost accounting + uint256 internal ghostQueuedErc20; + uint256 internal ghostQueuedNative; + uint256 internal totalRecorded; + uint256 internal totalTerminal; + + uint256[] internal liveIds; + + constructor() payable { + actors[0] = address(this); + actors[1] = address(0xA1); + actors[2] = address(0xA2); + + wrbtc = new EchWRBTC(); + token = new EchMockERC20(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](1); + sources[0] = address(this); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), ADMIN, address(wrbtc), MIN_DELAY, sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + queue.setNativePusher(address(this)); + + token.mint(address(this), type(uint128).max); + } + + receive() external payable {} + + // ── actions ── + + function recordErc20(uint128 amountSeed, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + uint128 amount = uint128(1 + (uint256(amountSeed) % 1e24)); + uint32 d = MIN_DELAY + (extraDelay % uint32(2 days)); + token.approve(address(queue), amount); + try queue.recordERC20Exit( + address(token), + amount, + d, + keccak256("S"), + address(0xBEEF), + actors[aSeed % 3], + actors[bSeed % 3], + actors[(aSeed + 1) % 3], + false + ) returns (uint256 id) { + liveIds.push(id); + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordNative(uint128 amountSeed, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + uint128 amount = uint128(1 + (uint256(amountSeed) % 1e20)); + if (address(this).balance < amount) return; + uint32 d = MIN_DELAY + (extraDelay % uint32(2 days)); + try queue.recordNativeExit{value: amount}( + amount, + d, + keccak256("Z"), + address(0), + actors[aSeed % 3], + actors[bSeed % 3], + actors[(bSeed + 1) % 3] + ) returns (uint256 id) { + liveIds.push(id); + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + function execute(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + try queue.executeExit(id) { + _onTerminal(r); + } catch {} + } + + /// @dev Gate-5 redirect leg (mirrors ExitDelayQueueHandler.recoverStuck). + /// recoverStuckExit(id, altReceiver) shares execute's {originator, owner} + /// authorization and on SUCCESS (stored-receiver or altReceiver branch) is + /// a TERMINAL transition, so ghost accounting decrements exactly like + /// execute. altReceiver is one of the plain EOA actors — never 0/this/ + /// token/wrbtc — so the guard doesn't mask the leg (actors[0] is this + /// harness, hence the 1 + altSeed % 2 pick). Lock/block/pause/terminal + /// failures revert and are caught (no state change). Property under test: + /// recovery never breaks solvency or double-spends, whichever branch runs. + function recoverStuck(uint256 idSeed, uint256 altSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + address altReceiver = actors[1 + (altSeed % 2)]; + try queue.recoverStuckExit(id, altReceiver) { + _onTerminal(r); + } catch {} + } + + function resolveBySIP(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + uint256[] memory ids = new uint256[](1); + ids[0] = id; + try queue.resolveBySIP(ids, address(0xD00D)) { + _onTerminal(r); + } catch {} + } + + function freeze(uint256 actorSeed) external { + try queue.freeze(actors[actorSeed % 3]) {} catch {} + } + + function blacklist(uint256 actorSeed) external { + try queue.blacklist(actors[actorSeed % 3]) {} catch {} + } + + function unfreeze(uint256 actorSeed) external { + try queue.unfreeze(actors[actorSeed % 3]) {} catch {} + } + + function unblacklist(uint256 actorSeed) external { + try queue.unblacklist(actors[actorSeed % 3]) {} catch {} + } + + function pause(bool p) external { + try queue.setSecurityPerimeterPaused(p) {} catch {} + } + + function sweep(bool native) external { + try queue.sweepSurplus(native ? address(0) : address(token), address(0x5EE)) {} catch {} + } + + function _onTerminal(IExitDelayQueue.ExitRequest memory r) internal { + totalTerminal++; + if (r.token == address(0)) { + ghostQueuedNative -= r.amount; + } else { + ghostQueuedErc20 -= r.amount; + } + } + + // ── properties ── + + function echidna_solvency_erc20() external view returns (bool) { + uint256 esc = queue.totalEscrowed(address(token)); + return esc == ghostQueuedErc20 && esc <= token.balanceOf(address(queue)); + } + + function echidna_solvency_native() external view returns (bool) { + uint256 esc = queue.totalEscrowed(address(0)); + return esc == ghostQueuedNative && esc <= address(queue).balance; + } + + function echidna_id_monotonic() external view returns (bool) { + return queue.lastRequestId() == totalRecorded; + } + + function echidna_no_double_terminal() external view returns (bool) { + return totalTerminal <= totalRecorded; + } +} diff --git a/test/echidna/echidna.yaml b/test/echidna/echidna.yaml new file mode 100644 index 0000000..ae38e71 --- /dev/null +++ b/test/echidna/echidna.yaml @@ -0,0 +1,10 @@ +# Echidna config for EchidnaExitDelayQueue (property mode, no cheatcodes). +# Run from the repo root: +# echidna . --contract EchidnaExitDelayQueue --config test/echidna/echidna.yaml \ +# --crytic-args --foundry-compile-all +# balanceContract funds the payable harness constructor so recordNative has +# native balance to escrow. +testMode: property +testLimit: 50000 +timeout: 300 +balanceContract: 100000000000000000000000 diff --git a/test/fixtures/BadV3.sol b/test/fixtures/BadV3.sol index 332fe4d..0c780d8 100644 --- a/test/fixtures/BadV3.sol +++ b/test/fixtures/BadV3.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// @dev Test fixture ONLY. NOT a real upgrade candidate. /// @@ -20,10 +21,25 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet /// RatePolicy entry after upgrade. /// /// tools/diff-storage-layouts.py MUST reject this layout against -/// a saved ExitFeeController.json. +/// a saved ExitFeeController.json with "shifted or changed shape". +/// +/// HOW TO REGENERATE (do this whenever ExitFeeController's storage +/// changes): mirror `forge inspect ExitFeeController storageLayout` +/// EXACTLY (slots 251..270 today, __gap unchanged at [30]) — the +/// ONLY intentional deviation is the LOCAL RatePolicy below whose +/// two members are swapped. Everything else must match byte-for-byte +/// so the tool rejects for the struct reorder and NOT for a missing +/// or moved variable. DelayBypassPolicy is imported from +/// IExitFeeController so it stays identical; only RatePolicy is +/// redefined locally to carry the reorder. contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; + // Same struct NAME, different member ORDER. (active before rateBps in the - // real ExitFeeController; here we swap them.) + // real ExitFeeController; here we swap them.) Both orderings occupy one + // 32-byte slot, so the OUTER layout is byte-identical — the reorder is + // only visible by deep-comparing the struct's member list. struct RatePolicy { uint16 rateBps; bool active; @@ -31,18 +47,38 @@ contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { uint16 public constant MAX_BPS = 10_000; + // ── Mirror of ExitFeeController own storage, slots 251..270 ────────── + + // slot 251 (packed: bool@0, address@1) bool public exitFeeEnabled; address public feeReceiver; + // slots 252..256 — use the LOCAL (reordered) RatePolicy. mapping(bytes32 => RatePolicy) internal _surfacePolicy; mapping(bytes32 => mapping(address => RatePolicy)) internal _subProductPolicy; mapping(bytes32 => mapping(address => RatePolicy)) internal _actorPolicy; mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; + // slot 257 (packed: bool@0, uint32@1, address@5) — DO NOT reorder. + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; address public admin; - uint256[43] private __gap; + // slots 258..270 — DelayBypassPolicy imported so it stays identical. + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; // slots 261..262 (2 slots) + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) + + // __gap unchanged — this fixture adds NO storage; it only reorders a struct. + uint256[30] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/test/fixtures/GoodPackedV2.sol b/test/fixtures/GoodPackedV2.sol index d425b14..14687b2 100644 --- a/test/fixtures/GoodPackedV2.sol +++ b/test/fixtures/GoodPackedV2.sol @@ -5,6 +5,7 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; +import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// @dev Test fixture ONLY. NOT a real upgrade candidate. /// @@ -18,39 +19,66 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet /// "2 new entries, span 1 each = 2 slots reclaimed" and reject /// this as inconsistent with a 1-slot gap shrinkage. The correct /// accounting (UNION of slot ranges) sees both entries at slot -/// 258 covering [258, 259) = 1 slot. +/// 271 covering [271, 272) = 1 slot. /// /// tools/diff-storage-layouts.py MUST accept this layout as /// upgrade-safe. +/// +/// HOW TO REGENERATE (do this whenever ExitFeeController's storage +/// changes): mirror `forge inspect ExitFeeController storageLayout` +/// EXACTLY — every own variable (slots 251..270 today), in the same +/// order, with the same struct types (imported from +/// IExitFeeController so the type definitions are byte-identical) — +/// then place the two packed uint128 fields at the FIRST previously +/// __gap slot and shrink __gap by 1 (30 -> 29). The mirror below is +/// current as of the security-perimeter delay-extension storage +/// (securityPerimeterEnabled / globalDelaySeconds / admin + bypass +/// tiers + passthrough registry + enumeration sets). contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { - struct RatePolicy { - bool active; - uint16 rateBps; - } + using EnumerableSet for EnumerableSet.AddressSet; + using EnumerableSet for EnumerableSet.Bytes32Set; uint16 public constant MAX_BPS = 10_000; - // Mirrors the live ExitFeeController layout exactly through slot 257. + // ── Mirror of ExitFeeController own storage, slots 251..270 ────────── + + // slot 251 (packed: bool@0, address@1) bool public exitFeeEnabled; address public feeReceiver; - mapping(bytes32 => RatePolicy) internal _surfacePolicy; - mapping(bytes32 => mapping(address => RatePolicy)) internal _subProductPolicy; - mapping(bytes32 => mapping(address => RatePolicy)) internal _actorPolicy; + // slots 252..256 + mapping(bytes32 => IExitFeeController.RatePolicy) internal _surfacePolicy; + mapping(bytes32 => mapping(address => IExitFeeController.RatePolicy)) internal _subProductPolicy; + mapping(bytes32 => mapping(address => IExitFeeController.RatePolicy)) internal _actorPolicy; mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; + // slot 257 (packed: bool@0, uint32@1, address@5) — DO NOT reorder. + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; address public admin; - // Two packed uint128 fields. Both go at slot 258 (the first slot - // previously inside __gap[43]). Solidity puts them at offset 0 and - // offset 16 of the SAME slot. The gap should shrink to __gap[42]. + // slots 258..270 + mapping(bytes32 => IExitFeeController.DelayBypassPolicy) internal _surfaceBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _subProductBypass; + mapping(bytes32 => mapping(address => IExitFeeController.DelayBypassPolicy)) internal _actorBypass; + EnumerableSet.Bytes32Set internal _surfaceBypassKeys; // slots 261..262 (2 slots) + mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductBypassKeys; + mapping(bytes32 => EnumerableSet.AddressSet) internal _actorBypassKeys; + mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; + mapping(bytes32 => EnumerableSet.AddressSet) internal _passthroughKeys; + EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) + EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) + + // Two packed uint128 fields. Both go at slot 271 (the first slot + // previously inside __gap[30]). Solidity puts them at offset 0 and + // offset 16 of the SAME slot. The gap should shrink to __gap[29]. uint128 public newA; uint128 public newB; // __gap shrinks by exactly 1 slot (one slot reclaimed for the two - // packed uint128 fields). - uint256[42] private __gap; + // packed uint128 fields): 30 -> 29. + uint256[29] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/test/invariant/ExitDelayQueue.invariant.t.sol b/test/invariant/ExitDelayQueue.invariant.t.sol new file mode 100644 index 0000000..a4ccf4f --- /dev/null +++ b/test/invariant/ExitDelayQueue.invariant.t.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; +import {ExitDelayQueueHandler, InvMockERC20} from "./ExitDelayQueueHandler.sol"; + +/// @dev Minimal native-backed WRBTC for the invariant run (unused by the +/// handler's ingress but required by initialize()). +contract InvWRBTC { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @notice Stateful invariant suite for `ExitDelayQueue` covering its +/// invariants. The handler (owner + source + pusher) fuzzes ingress, +/// execution, blocks, recovery, pause, sweep and time. Assertions read +/// ghost accounting + on-chain state. +contract ExitDelayQueueInvariant is Test { + ExitDelayQueue queue; + InvMockERC20 token; + InvWRBTC wrbtc; + ExitDelayQueueHandler handler; + + address constant ADMIN = address(0xAD); + + function setUp() public { + wrbtc = new InvWRBTC(); + token = new InvMockERC20(); + + ExitDelayQueue impl = new ExitDelayQueue(); + // The handler will be the owner; deploy with the test as temp owner then + // hand off. Sources include the handler; admin is a distinct dummy. + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), ADMIN, address(wrbtc), uint32(1 hours), sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + handler = new ExitDelayQueueHandler(queue, token, address(wrbtc)); + + // Wire the handler as an allowed source + native pusher, then transfer + // ownership to it so it can also exercise owner/admin-gated fns. + queue.addAllowedSource(address(handler)); + queue.setNativePusher(address(handler)); + queue.transferOwnership(address(handler)); + vm.prank(address(handler)); + queue.acceptOwnership(); + + // target only the handler + targetContract(address(handler)); + + bytes4[] memory selectors = new bytes4[](17); + selectors[0] = handler.recordErc20.selector; + selectors[1] = handler.recordNative.selector; + selectors[2] = handler.execute.selector; + selectors[3] = handler.freeze.selector; + selectors[4] = handler.blacklist.selector; + selectors[5] = handler.unfreeze.selector; + selectors[6] = handler.unblacklist.selector; + selectors[7] = handler.pause.selector; + selectors[8] = handler.resolveBySIP.selector; + selectors[9] = handler.sweep.selector; + selectors[10] = handler.warp.selector; + // coverage: measured-delta ingress + donation/force-send surplus. + selectors[11] = handler.recordReceivedErc20.selector; + selectors[12] = handler.recordReceivedNative.selector; + selectors[13] = handler.donateErc20.selector; + selectors[14] = handler.donateNative.selector; + // (C3): fuzz floor raises/lowers so the creation-time invariant is + // exercised against live short requests without falsely tripping. + selectors[15] = handler.setMinDelay.selector; + // Gate-5 stuck-exit recovery: recoverStuckExit(id, altReceiver) must never + // break solvency / double-spend, whichever branch it takes (stored-receiver + // pay, altReceiver pay on a bounce, or whole-call revert on block/lock/pause). + selectors[16] = handler.recoverStuck.selector; + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + // Per-token solvency — totalEscrowed == Σ Queued amounts, and + // ≤ backing balance. Ghost sum tracks Σ Queued. + function invariant_solvency_erc20() public view { + assertEq(queue.totalEscrowed(address(token)), handler.ghostQueuedErc20()); + assertLe(queue.totalEscrowed(address(token)), token.balanceOf(address(queue))); + } + + function invariant_solvency_native() public view { + assertEq(queue.totalEscrowed(address(0)), handler.ghostQueuedNative()); + assertLe(queue.totalEscrowed(address(0)), address(queue).balance); + } + + // ids monotonic and never reused (lastRequestId only grows; every + // recorded id ≤ lastRequestId and unique by construction of ++lastRequestId). + function invariant_id_monotonic() public view { + assertEq(queue.lastRequestId(), handler.totalRecorded()); + } + + // A request leaves Queued at most once. Every id is in exactly + // one of {Queued, terminal}; terminal count never exceeds recorded count. + function invariant_no_double_terminal() public view { + assertLe(handler.totalTerminal(), handler.totalRecorded()); + } + + // + metadata immutable + status monotonic across all live ids. + // Also active-index membership biconditional (Queued ⇔ in set). + function invariant_status_and_index_consistency() public view { + uint256 n = handler.liveIdCount(); + for (uint256 i = 0; i < n; ++i) { + uint256 id = handler.liveIdAt(i); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + // (creation-time, C3): every request satisfies + // `unlockAt − createdAt ≥ the minimumDelaySeconds in effect at ITS OWN + // createdAt` (the floor is enforced once, at record time). We check + // against the handler's recorded floor-at-creation, NOT the current + // live floor — a later setMinimumDelaySeconds raise applies only to NEW + // requests and must never retroactively extend an already-Queued exit, + // so asserting against the live floor would falsely trip after a raise. + assertGe(uint256(r.unlockAt) - uint256(r.createdAt), uint256(handler.floorAtCreation(id))); + // for the originator, id ∈ active iff Queued. + bool inSet = _inActive(r.originator, id); + if (r.status == IExitDelayQueue.ExitStatus.Queued) { + assertTrue(inSet, "queued id must be in originator active set"); + } else { + assertFalse(inSet, "terminal id must NOT be in originator active set"); + assertFalse(_inActive(r.owner, id), "terminal id must NOT be in owner active set"); + } + } + } + + function _inActive(address party, uint256 id) internal view returns (bool) { + // page through getActive (bounded live sets in the run) + uint256 cursor = 0; + for (uint256 guard = 0; guard < 50; ++guard) { + (uint256[] memory ids, uint256 next) = queue.getActive(party, cursor, 100); + for (uint256 j = 0; j < ids.length; ++j) { + if (ids[j] == id) return true; + } + if (next == 0) break; + cursor = next; + } + return false; + } +} diff --git a/test/invariant/ExitDelayQueueHandler.sol b/test/invariant/ExitDelayQueueHandler.sol new file mode 100644 index 0000000..c42111f --- /dev/null +++ b/test/invariant/ExitDelayQueueHandler.sol @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +contract InvMockERC20 is ERC20 { + constructor() ERC20("Inv", "INV") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Force-sends native RBTC via selfdestruct — bypasses the queue's +/// `receive` gate, exactly the donation-grief vector the tolerates. +contract ForceSender { + constructor(address payable target) payable { + selfdestruct(target); + } +} + +/// @dev Stateful handler that drives the queue through its whole surface for the +/// forge invariant runner. It is itself an `_allowedSource` and the +/// `nativePusher`, so it can exercise every ingress variant. It tracks a +/// ghost sum of Queued amounts per token to check solvency and +/// records executed ids to check monotonicity / no-double-spend. +/// Bounded actor set keeps blocks meaningful. +contract ExitDelayQueueHandler is Test { + ExitDelayQueue public queue; + InvMockERC20 public token; + address public wrbtc; // canonical WRBTC (native-backed mock in the test) + + // three fixed actors → freeze/execute interplay is meaningful + address[3] public actors = [address(0xA1), address(0xA2), address(0xA3)]; + + uint32 public constant MIN_DELAY = 1 hours; + + // ghost accounting + uint256 public ghostQueuedErc20; // Σ Queued amounts, token + uint256 public ghostQueuedNative; // Σ Queued amounts, native + uint256 public totalRecorded; // # of record* calls that succeeded + uint256 public totalTerminal; // # of terminal transitions + + // track live ids for targeted execute/resolve + uint256[] public liveIds; + + /// @dev (creation-time, C3): the minimumDelaySeconds floor in effect at + /// each request's OWN createdAt. Recorded at the moment of a successful + /// record so the invariant can check `unlockAt − createdAt >= floor-at-its- + /// creation` — NOT the current live floor. A later setMinimumDelaySeconds + /// raise must apply only to NEW requests and never retroactively extend an + /// already-Queued exit; this ghost is what lets the invariant assert that. + mapping(uint256 => uint32) public floorAtCreation; + + constructor(ExitDelayQueue q, InvMockERC20 t, address wrbtc_) { + queue = q; + token = t; + wrbtc = wrbtc_; + token.mint(address(this), type(uint128).max); + vm.deal(address(this), type(uint128).max); + } + + receive() external payable {} + + // ── ingress ── + + /// @dev Overflow-safe actor trio. The receiver index is + /// `(seed%3 + 1) % 3` — computed here off the caller's stack (keeping the + /// record* frames under the non-via-ir stack limit) and, crucially, on the + /// ALREADY-reduced `%3` value so a max seed can never 0x11-overflow the + /// way the prior `(seed+1)%3` did. `orig`/`ownr` use the raw `%3` seeds so + /// the freeze/execute interplay still spans the whole actor set. + function _trio(uint256 aSeed, uint256 bSeed) + internal + view + returns (address orig, address ownr, address recvA, address recvB) + { + orig = actors[aSeed % 3]; + ownr = actors[bSeed % 3]; + recvA = actors[(aSeed % 3 + 1) % 3]; + recvB = actors[(bSeed % 3 + 1) % 3]; + } + + function recordErc20(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e24)); + // Delay is bounded above the live floor so a floor RAISE (see setMinDelay) + // never bricks ingress here; the request's floor-at-creation is captured + // below regardless of the delay actually chosen. + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr, address recv,) = _trio(aSeed, bSeed); + token.approve(address(queue), amount); + try queue.recordERC20Exit( + address(token), amount, d, keccak256("S"), address(0xBEEF), orig, ownr, recv, false + ) returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordNative(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e21)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr,, address recv) = _trio(aSeed, bSeed); + try queue.recordNativeExit{value: amount}(amount, d, keccak256("Z"), address(0), orig, ownr, recv) + returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + // ── measured-delta ingress: push then record in one tx. Credit is + // exactly `amount` when the non-backing surplus delta >= amount, so a + // donation (see donate*) must NOT brick these and must NOT break solvency. + + function recordReceivedErc20(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e24)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr, address recv,) = _trio(aSeed, bSeed); + token.transfer(address(queue), amount); // push in the same tx + try queue.recordReceivedERC20Exit( + address(token), amount, d, keccak256("S"), address(0xBEEF), orig, ownr, recv + ) returns (uint256 id) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedErc20 += amount; + totalRecorded++; + } catch {} + } + + function recordReceivedNative(uint128 amount, uint32 extraDelay, uint256 aSeed, uint256 bSeed) external { + amount = uint128(bound(amount, 1, 1e21)); + uint32 floor = queue.minimumDelaySeconds(); + uint32 d = floor + uint32(bound(extraDelay, 0, 10 days)); + (address orig, address ownr,, address recv) = _trio(aSeed, bSeed); + // The handler is the registered nativePusher, so this push clears receive(). + (bool ok,) = payable(address(queue)).call{value: amount}(""); + if (!ok) return; + try queue.recordReceivedNativeExit(amount, d, keccak256("Z"), address(0), orig, ownr, recv) returns ( + uint256 id + ) { + liveIds.push(id); + floorAtCreation[id] = floor; + ghostQueuedNative += amount; + totalRecorded++; + } catch {} + } + + // ── donation / force-send: creates non-backing surplus. The measured-delta + // credit-exactly-amount rule must keep / intact and never let a + // donation get mis-credited into totalEscrowed (grief resistance). + + function donateErc20(uint128 amount) external { + amount = uint128(bound(amount, 1, 1e18)); + token.transfer(address(queue), amount); // ghost NOT updated: pure surplus + } + + function donateNative(uint128 amount) external { + amount = uint128(bound(amount, 1, 1e18)); + // selfdestruct force-send bypasses the receive() gate entirely. + new ForceSender{value: amount}(payable(address(queue))); + } + + // ── execution ── + + function execute(uint256 idSeed, uint256 actorSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + // jump past unlock sometimes + if (actorSeed % 2 == 0 && block.timestamp < r.unlockAt) { + vm.warp(r.unlockAt); + } + address caller = actors[actorSeed % 3]; + vm.prank(caller); + try queue.executeExit(id) { + _onTerminal(r); + } catch {} + } + + // ── block model ── + + function freeze(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.freeze(a) {} catch {} + } + + function blacklist(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.blacklist(a) {} catch {} + } + + function unfreeze(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.unfreeze(a) {} catch {} + } + + function unblacklist(uint256 actorSeed) external { + address a = actors[actorSeed % 3]; + try queue.unblacklist(a) {} catch {} + } + + function pause(bool p) external { + try queue.setSecurityPerimeterPaused(p) {} catch {} + } + + /// @dev (C3): fuzz the per-request floor across a RANGE that spans both + /// below and (crucially) ABOVE the delays of already-Queued requests. A + /// raise here must NOT retroactively extend any live request's unlockAt — + /// the creation-time invariant checks each request against its own + /// floorAtCreation, never the live floor, so a raise while short requests + /// sit Queued must not trip invariant_status_and_index_consistency. + function setMinDelay(uint32 newFloor) external { + // Cap well above the max ingress delay (floor + 10 days) so raises that + // exceed live requests' (unlockAt − createdAt) are reachable and exercised. + newFloor = uint32(bound(newFloor, 0, 30 days)); + try queue.setMinimumDelaySeconds(newFloor) {} catch {} + } + + // ── stuck-exit recovery — verify-by-attempting redirect leg ── + + /// @dev recoverStuckExit(id, altReceiver): same {originator, owner} authorization + /// as execute. Attempts the STORED receiver first and pays altReceiver only + /// on a genuine bounce (verify-by-attempting) — but ON SUCCESS (either + /// branch) it is a TERMINAL transition, so ghost accounting must decrement, + /// exactly like execute. On a block/lock/pause/terminal/guard it reverts and + /// is caught (no state change). altReceiver is one of the plain handler + /// actors (never 0/this/token/wrbtc), so the guard never trips here. The + /// invariant property: recovery NEVER breaks solvency or double-spends, + /// whichever branch it takes. + function recoverStuck(uint256 idSeed, uint256 actorSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + if (actorSeed % 2 == 0 && block.timestamp < r.unlockAt) { + vm.warp(r.unlockAt); + } + address caller = actors[actorSeed % 3]; + address altReceiver = actors[(actorSeed / 3) % 3]; + vm.prank(caller); + try queue.recoverStuckExit(id, altReceiver) { + _onTerminal(r); + } catch {} + } + + // ── recovery ── + + function resolveBySIP(uint256 idSeed) external { + if (liveIds.length == 0) return; + uint256 id = liveIds[idSeed % liveIds.length]; + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + if (r.status != IExitDelayQueue.ExitStatus.Queued) return; + uint256[] memory ids = new uint256[](1); + ids[0] = id; + try queue.resolveBySIP(ids, address(0xD00D)) { + _onTerminal(r); + } catch {} + } + + // ── sweep (surplus removal keeps equality-form solvency reachable) ── + + function sweep(bool native) external { + try queue.sweepSurplus(native ? address(0) : address(token), address(0x5EE)) {} catch {} + } + + function warp(uint32 dt) external { + vm.warp(block.timestamp + bound(dt, 1, 5 days)); + } + + function _onTerminal(IExitDelayQueue.ExitRequest memory r) internal { + totalTerminal++; + if (r.token == address(0)) { + ghostQueuedNative -= r.amount; + } else { + ghostQueuedErc20 -= r.amount; + } + } + + function liveIdCount() external view returns (uint256) { + return liveIds.length; + } + + function liveIdAt(uint256 i) external view returns (uint256) { + return liveIds[i]; + } +} diff --git a/test/unit/DeployQueueAndWire.t.sol b/test/unit/DeployQueueAndWire.t.sol new file mode 100644 index 0000000..0604660 --- /dev/null +++ b/test/unit/DeployQueueAndWire.t.sol @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../../src/interfaces/IExitDelayQueueHost.sol"; +import {DeployQueueAndWire} from "../../script/05_DeployQueueAndWire.s.sol"; + +/// @dev Minimal WRBTC stand-in for the queue's `wrbtc_` init param (only its +/// address matters for the deploy path). +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + receive() external payable {} +} + +/// @dev Minimal product-host stand-in implementing the `setExitDelayQueue` +/// pointer. Mirrors the real host's unstructured-slot pointer +/// + Owner-gated setter well enough for the wire step to be exercised +/// end-to-end. Reverts writes from a non-owner (the real host is +/// Owner/Timelock-gated). +contract MockProductHost is IExitDelayQueueHost { + address public owner; + address public exitDelayQueue; + + constructor(address owner_) { + owner = owner_; + } + + function setExitDelayQueue(address queue) external override { + require(msg.sender == owner, "MockProductHost: not owner"); + exitDelayQueue = queue; + } +} + +/// @title SP2-CTRL-02-ordering — 05_DeployQueueAndWire run() + host-wire path +/// @notice Exercises the ACTUAL `run(uint256)` broadcast path end-to-end (deploy +/// the queue behind its proxy + wire `setExitDelayQueue` on the supplied +/// product hosts), which previously had ZERO coverage. Also pins the +/// core SP2-CTRL-02-ordering fix: the deploy path DELIBERATELY does NOT +/// configure the controller's admin / globalDelaySeconds (activation steps +/// 4–5 are Owner actions) and does NOT assert the go-live gates (those +/// moved to 06_VerifyActivation) — so a correctly-ordered first deploy, +/// run BEFORE the controller is configured, no longer self-aborts. +contract DeployQueueAndWireRunTest is Test { + address constant CTRL_OWNER = address(0xC0FFEE); + address constant QUEUE_OWNER = address(0x0E7E7); + address constant QUEUE_ADMIN = address(0x6DA12D); // != owner (queue enforces Admin!=Owner) + address constant SOURCE = address(0x50117CE); + + // A stand-in queue-pointer address for the direct `wireHosts` tests (the wire + // step is agnostic to what the pointer points at — it just sets + reads it). + address constant QUEUE_PTR = address(0x0DE10A); + + DeployQueueAndWire script; + MockWRBTC wrbtc; + ExitFeeController controller; + + function setUp() public { + script = new DeployQueueAndWire(); + wrbtc = new MockWRBTC(); + + // ── Controller proxy (mirrors 03_DeployController), left UNCONFIGURED: + // admin==0, globalDelaySeconds==0 (activation steps 4–5 not run) — the deploy + // must not depend on it (SP2-CTRL-02-ordering). No deployment artifact is + // written here: this suite drives the deploy+wire logic directly + // (validateConfig / wireHosts / a direct proxy deploy), NOT env→run() — + // see the "run() env→broadcast coverage" note below. ── + ExitFeeController cImpl = new ExitFeeController(); + bytes memory cInit = abi.encodeWithSelector(ExitFeeController.initialize.selector, CTRL_OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(cImpl), cInit))); + } + + // ── run() env→broadcast coverage lives in ONE place only ── + // The full env→run() deploy+wire path mutates PROCESS-global env via + // `vm.setEnv` (EXIT_DELAY_QUEUE_OWNER / *_HOST / DEFER_HOSTS / …), which forge + // does NOT isolate — and it runs test SUITES in parallel threads, so another + // suite's `vm.setEnv` of the SAME keys (06_VerifyActivation also drives an + // env→run() gate over SOVRYN_PROTOCOL_HOST/…) can land BETWEEN this suite's + // set and read, corrupting the config mid-test (a leaked non-zero host would + // make `wireHosts` call a non-contract → revert). To stay deterministic we + // therefore do NOT drive `run()` through env here. Instead the deploy+wire + // logic is pinned RACE-FREE by: + // • `validateConfig(...)` — every C1/C2 no-silent-blank abort branch, on an + // in-memory struct (pure, no env); + // • `wireHosts(...)` — the deploy's host-wire step directly, incl. the + // wiredCount return that drives the conditional success line (no env); + // • the queue-init assertions below, via a direct proxy deploy (no env). + // 06_VerifyActivation's `test_run_passes_cleanly_when_both_hosts_wired` + // is the single env→run() smoke test across these two script suites. + + // ── The deploy step's queue-init (owner/admin/floor from initialize) is pinned + // by deploying the exact impl+proxy the script deploys, with the same init + // encoding — NO env, so it cannot race. Proves the correctly-ordered first + // deploy yields a queue owned by the intended Owner (NOT the deployer) with + // the guardian + floor set, independent of the (still-unconfigured) + // controller. ── + function test_deploy_initializes_queue_owner_admin_floor() public { + // The controller is deliberately left unconfigured (admin==0, delay==0) — + // the deploy must not depend on it (SP2-CTRL-02-ordering). + assertEq(controller.admin(), address(0), "precondition: controller admin unset"); + assertEq(uint256(controller.globalDelaySeconds()), 0, "precondition: global delay unset"); + + address[] memory sources = new address[](1); + sources[0] = SOURCE; + + address queueImpl = address(new ExitDelayQueue()); + address queueProxy = address( + new ERC1967Proxy( + queueImpl, + abi.encodeCall( + ExitDelayQueue.initialize, + (QUEUE_OWNER, QUEUE_ADMIN, address(wrbtc), uint32(3600), sources) + ) + ) + ); + + assertTrue(queueProxy != queueImpl, "proxy != impl"); + + ExitDelayQueue queue = ExitDelayQueue(payable(queueProxy)); + assertEq(queue.owner(), QUEUE_OWNER, "queue owner from init (NOT deployer)"); + assertEq(queue.admin(), QUEUE_ADMIN, "queue admin from init"); + assertEq(uint256(queue.minimumDelaySeconds()), 3600, "queue floor from init"); + assertTrue(queue.isAllowedSource(SOURCE), "allowed source seeded from init"); + } + + // ── C1/C2 no-silent-blanks aborts are driven through the PURE `validateConfig` + // entrypoint (NOT env→run()), so each abort branch is pinned deterministically + // without racing on process-global env. `_validCfg()` builds a deployable + // baseline; each test zeroes exactly one field and asserts the distinct abort. ── + + function _validCfg() internal view returns (DeployQueueAndWire.DeployConfig memory cfg) { + cfg.queueOwner = QUEUE_OWNER; + cfg.queueAdmin = QUEUE_ADMIN; + cfg.wrbtc = address(wrbtc); + cfg.minDelay = 3600; + cfg.allowedSources = new address[](0); + cfg.sovrynHost = address(0xBEEF); + cfg.zeroHost = address(0xCAFE); + } + + // ── (C1) A blank queue owner ABORTS — a zero owner would let ExitDelayQueue. + // initialize resolve owner_ to msg.sender, silently leaving the deployer EOA + // holding queue authority. Fail loud, never default to 0. ── + function test_validateConfig_reverts_when_queue_owner_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueOwner = address(0); + vm.expectRevert( + bytes( + "05: EXIT_DELAY_QUEUE_OWNER must be set (C1: zero owner => deployer EOA holds queue authority)" + ) + ); + script.validateConfig(cfg, false); + } + + // ── (C1) A blank queue admin ABORTS. ── + function test_validateConfig_reverts_when_queue_admin_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueAdmin = address(0); + vm.expectRevert(bytes("05: EXIT_DELAY_QUEUE_ADMIN must be set")); + script.validateConfig(cfg, false); + } + + // ── (C1) Admin == Owner ABORTS (Admin != Owner separation). ── + function test_validateConfig_reverts_when_admin_equals_owner() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.queueAdmin = cfg.queueOwner; + vm.expectRevert( + bytes("05: EXIT_DELAY_QUEUE_ADMIN must differ from EXIT_DELAY_QUEUE_OWNER (Admin != Owner)") + ); + script.validateConfig(cfg, false); + } + + // ── (C1) A blank WRBTC address ABORTS. ── + function test_validateConfig_reverts_when_wrbtc_blank() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.wrbtc = address(0); + vm.expectRevert(bytes("05: WRBTC_ADDRESS must be set")); + script.validateConfig(cfg, false); + } + + // ── (C2) A blank sovryn host WITHOUT DEFER_HOSTS ABORTS — a missing host must + // never no-op-wire a fail-open zero-delay surface. ── + function test_validateConfig_reverts_when_sovryn_host_blank_and_not_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST must be set (or set DEFER_HOSTS=true to defer)")); + script.validateConfig(cfg, false); + } + + // ── (C2) A blank Zero host WITHOUT DEFER_HOSTS ABORTS. ── + function test_validateConfig_reverts_when_zero_host_blank_and_not_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.zeroHost = address(0); + vm.expectRevert( + bytes("05: ZERO_BORROWER_OPERATIONS_HOST must be set (or set DEFER_HOSTS=true to defer)") + ); + script.validateConfig(cfg, false); + } + + // ── (C2) With DEFER_HOSTS=true, BOTH hosts may be blank — an EXPLICIT deferral + // is not an error (validateConfig returns silently). ── + function test_validateConfig_allows_blank_hosts_when_deferred() public view { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + cfg.zeroHost = address(0); + script.validateConfig(cfg, true); // does not revert + } + + // ── (SP2-G5R2-01 / GATE4-02) A NON-ZERO duplicate host pair (both spec-named + // vars pointing at the SAME address — copy-paste footgun) ABORTS: wiring one + // surface twice would leave the OTHER silently unwired at zero-delay. ── + function test_validateConfig_reverts_on_duplicate_nonzero_hosts() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0xDEAD); + cfg.zeroHost = address(0xDEAD); // same non-zero host as sovryn + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)")); + script.validateConfig(cfg, false); + } + + // ── The duplicate check fires even under DEFER_HOSTS=true when the + // pair is a NON-ZERO duplicate — deferral excuses a ZERO host, never a genuine + // copy-paste of the same real address into both slots. ── + function test_validateConfig_reverts_on_duplicate_nonzero_hosts_even_when_deferred() public { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0xDEAD); + cfg.zeroHost = address(0xDEAD); + vm.expectRevert(bytes("05: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)")); + script.validateConfig(cfg, true); + } + + // ── The both-ZERO pair under DEFER_HOSTS=true is NOT a duplicate — + // it is an explicit both-host deferral and must still pass (regression guard + // that the `|| == address(0)` clause preserves the defer-both path). ── + function test_validateConfig_both_zero_hosts_not_treated_as_duplicate_when_deferred() public view { + DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); + cfg.sovrynHost = address(0); + cfg.zeroHost = address(0); + script.validateConfig(cfg, true); // does not revert — both-zero defer, not a dup + } + + // ── A fully-valid config passes validateConfig with hosts required. ── + function test_validateConfig_passes_on_valid_config() public view { + script.validateConfig(_validCfg(), false); // does not revert + } + + // NOTE on env isolation: `vm.setEnv` mutates PROCESS-global env that forge does + // NOT isolate between the (parallel) test functions of one contract. So EXACTLY + // ONE test here drives the full env→run() broadcast path (above), and the host- + // WIRE coverage below drives the script's `wireHosts` entrypoint DIRECTLY (no + // env, no broadcast) — same on-chain wire logic, race-free. This also keeps the + // wire assertions decoupled from the queue-deploy so a wiring regression is + // pinned independently of the deploy path. + + // ── wireHosts wires the queue pointer into each supplied host + returns 2. ── + function test_wireHosts_wires_both_supplied_hosts() public { + // These direct calls are NOT under `startBroadcast`, so `msg.sender` seen by + // the host is `address(script)` — own the hosts by the script accordingly. + MockProductHost sovrynHost = new MockProductHost(address(script)); + MockProductHost zeroHost = new MockProductHost(address(script)); + + uint256 wired = script.wireHosts(QUEUE_PTR, address(sovrynHost), address(zeroHost)); + + assertEq(wired, 2, "both hosts counted as wired (C2 success-line driver)"); + assertEq(sovrynHost.exitDelayQueue(), QUEUE_PTR, "sovryn host wired"); + assertEq(zeroHost.exitDelayQueue(), QUEUE_PTR, "zero host wired"); + } + + // ── An unset (zero) host is SKIPPED (wiring deferred to a per-host SIP); the + // other supplied host is still wired, and only it is counted (wiredCount==1). ── + function test_wireHosts_skips_unset_host_and_wires_the_other() public { + MockProductHost sovrynHost = new MockProductHost(address(script)); + + uint256 wired = script.wireHosts(QUEUE_PTR, address(sovrynHost), address(0)); + + assertEq(wired, 1, "only the supplied host counted (C2)"); + assertEq(sovrynHost.exitDelayQueue(), QUEUE_PTR, "supplied host wired"); + } + + // ── Both hosts unset: wireHosts is a pure no-op and counts 0 (drives the + // "NO hosts wired" branch of the C2 conditional success line). ── + function test_wireHosts_both_unset_is_noop() public { + uint256 wired = script.wireHosts(QUEUE_PTR, address(0), address(0)); + assertEq(wired, 0, "no hosts wired (C2 deferred-all success line)"); + } + + // ── wireHosts reverts if the pointer write is unauthorized on a host (the + // caller is not the host Owner) — surfacing a mis-authorized deploy. ── + function test_wireHosts_reverts_when_host_write_unauthorized() public { + // Host owned by someone ELSE — the script cannot set the pointer. + MockProductHost sovrynHost = new MockProductHost(address(0xB0B)); + vm.expectRevert(bytes("MockProductHost: not owner")); + script.wireHosts(QUEUE_PTR, address(sovrynHost), address(0)); + } + + // ── wireHosts reverts if the write silently does not take (host accepts the + // call but the pointer read-back mismatches) — the read-back guard. ── + function test_wireHosts_reverts_when_pointer_does_not_take() public { + NoopHost badHost = new NoopHost(); // accepts setExitDelayQueue but stores nothing + vm.expectRevert(bytes("05: setExitDelayQueue did not take on host")); + script.wireHosts(QUEUE_PTR, address(badHost), address(0)); + } +} + +/// @dev A host that ACCEPTS `setExitDelayQueue` (no revert, no auth) but never +/// stores the pointer, so `exitDelayQueue()` stays 0 — exercises the wire +/// read-back guard in `_wireHost`. +contract NoopHost is IExitDelayQueueHost { + function setExitDelayQueue(address) external override {} + + function exitDelayQueue() external pure override returns (address) { + return address(0); + } +} diff --git a/test/unit/ExitDelayQueue.t.sol b/test/unit/ExitDelayQueue.t.sol new file mode 100644 index 0000000..5acb24b --- /dev/null +++ b/test/unit/ExitDelayQueue.t.sol @@ -0,0 +1,2094 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +// ─── Mocks ────────────────────────────────────────────────────────────── + +contract MockERC20 is ERC20 { + constructor() ERC20("Mock", "MOCK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Fee-on-transfer token: keeps 1 wei on every transfer. Used to prove the +/// receipt-proof ingress rejects a mismatched received amount. +contract FeeOnTransferERC20 is ERC20 { + constructor() ERC20("Fot", "FOT") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function _transfer(address from, address to, uint256 value) internal override { + // OZ 4.9: split into a 1-wei burn + (value-1) delivered, so the + // recipient measures value-1 (fee-on-transfer behavior). + super._transfer(from, address(0xdead), 1); + super._transfer(from, to, value - 1); + } +} + +/// @dev Minimal WRBTC: mints on deposit-equivalent, burns + sends native on +/// withdraw (unwrap path). +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok, "wrbtc withdraw"); + } + + receive() external payable {} +} + +/// @dev A source contract that pulls funds from the test and calls the queue's +/// pull-ingress. Mirrors an iToken proxy (`_allowedSource`). +contract SourceHarness { + ExitDelayQueue public queue; + + constructor(ExitDelayQueue q) { + queue = q; + } + + function recordERC20( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrap + ) external returns (uint256) { + ERC20(token).approve(address(queue), amount); + return queue.recordERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver, unwrap + ); + } + + function recordReceivedERC20( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256) { + // push then record (measured-delta path) + ERC20(token).transfer(address(queue), amount); + return queue.recordReceivedERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver + ); + } + + function recordNative( + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external payable returns (uint256) { + // Forward the caller-supplied msg.value (which may deliberately differ + // from `amount` in the mismatch test) so the queue's msg.value==amount + // guard is actually exercised. + return queue.recordNativeExit{value: msg.value}( + amount, d, surfaceId, subProduct, effOrig, effOwner, receiver + ); + } + + function recordReceivedNative( + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver + ) external returns (uint256) { + return queue.recordReceivedNativeExit(amount, d, surfaceId, subProduct, effOrig, effOwner, receiver); + } +} + +/// @dev A native pusher (Zero ActivePool). Pushes value into the queue's +/// receive() before the record call fires in the same tx. +contract NativePusherHarness { + function push(address payable queue, uint256 amount) external { + (bool ok,) = queue.call{value: amount}(""); + require(ok, "push"); + } + + receive() external payable {} +} + +/// @dev A receiver that always reverts on receive — proves fail-closed payout. +contract RevertingReceiver { + receive() external payable { + revert("no"); + } +} + +/// @dev ERC20 whose transferFrom re-enters the queue's ingress. Proves +/// the `nonReentrant` guard on the four record* fns rejects a re-entrant +/// record during the token pull. The reentrant call MUST revert with the +/// OZ 4.9 require string "ReentrancyGuard: reentrant call"; the harness +/// surfaces both whether it reverted and the exact revert reason. +contract ReentrantERC20 is ERC20 { + ExitDelayQueue public queue; + bool public armed; + bool public reentered; + bool public reentryReverted; + string public reentryRevertReason; + + constructor() ERC20("Reenter", "RNT") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setQueue(ExitDelayQueue q) external { + queue = q; + } + + function arm() external { + armed = true; + } + + function _transfer(address from, address to, uint256 value) internal override { + super._transfer(from, to, value); + if (armed && from != address(0)) { + armed = false; // one-shot so we don't recurse forever + reentered = true; + // Re-enter the SAME pull ingress mid-transfer; nonReentrant must trip. + try queue.recordERC20Exit( + address(this), + uint128(1), + 2 hours, + keccak256("S"), + address(0xB00C), + address(0x111), + address(0x222), + address(0x333), + false + ) { + reentryReverted = false; + } catch Error(string memory reason) { + // String revert (require) — capture the reason so the test can + // assert it was specifically the ReentrancyGuard, not some other + // require (e.g. an unregistered-source authorization revert). + reentryReverted = true; + reentryRevertReason = reason; + } catch { + // Non-string revert (custom error / panic): still record that a + // revert happened, but leave the reason empty so a string + // assertion in the test fails and surfaces the wrong cause. + reentryReverted = true; + } + } + } +} + +/// @dev Minimal UUPS-upgrade target used to prove `_authorizeUpgrade` accepts a +/// valid, non-zero implementation from the Owner (the happy branch that the +/// `UpgradeImplZero` guard does NOT trip). Adds one new getter so we can +/// confirm the proxy is now running the v2 code post-upgrade. +contract ExitDelayQueueV2 is ExitDelayQueue { + function version() external pure returns (uint256) { + return 2; + } +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +contract ExitDelayQueueTest is Test { + // Local event redecls for vm.expectEmit (0.8.20 can't `emit Iface.Event`). + event ExitQueued( + uint256 indexed id, + address indexed originator, + address indexed owner, + address receiver, + address token, + uint128 amount, + uint64 unlockAt, + bytes32 surfaceId, + address subProduct + ); + event ExitExecuted(uint256 indexed id, address indexed receiver, address token, uint128 amount); + event AccountBlocked( + address indexed account, + IExitDelayQueue.BlockState state, + uint256 indexed triggerRequestId, + bytes32 reasonHash + ); + event SurplusSwept(address indexed token, address indexed to, uint256 amount); + + ExitDelayQueue queue; + MockERC20 token; + MockWRBTC wrbtc; + SourceHarness source; + NativePusherHarness pusher; + + address constant OWNER = address(0x0E1); + address constant ADMIN = address(0xAd11); + address constant OUTSIDER = address(0xC0); + address constant ORIG = address(0x0111); + address constant OWNR = address(0x0222); + address constant RCVR = address(0x0333); + + bytes32 constant SURFACE = keccak256("COLFEE:LENDING_LENDER_WITHDRAW"); + bytes32 constant SURFACE_ZERO = keccak256("COLFEE:ZERO_WITHDRAW_COLL"); + address constant SUBPRODUCT = address(0xB00C); + + uint32 constant MIN_DELAY = 1 hours; + uint32 constant DELAY = 2 hours; + + function setUp() public { + wrbtc = new MockWRBTC(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + token = new MockERC20(); + source = new SourceHarness(queue); + pusher = new NativePusherHarness(); + + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + + // fund the source & pusher + token.mint(address(source), 1_000_000 ether); + vm.deal(address(source), 1_000_000 ether); + vm.deal(address(pusher), 1_000_000 ether); + wrbtc.mint(address(source), 1_000_000 ether); + vm.deal(address(wrbtc), 1_000_000 ether); // back the unwrap + } + + // ── helpers ── + + function _queueErc20(uint128 amount) internal returns (uint256 id) { + vm.prank(address(this)); + id = source.recordERC20(address(token), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + // ── initialization ── + + function test_initialize_sets_state() public view { + assertEq(queue.owner(), OWNER); + assertEq(queue.admin(), ADMIN); + assertEq(queue.wrbtc(), address(wrbtc)); + assertEq(queue.minimumDelaySeconds(), MIN_DELAY); + assertEq(queue.lastRequestId(), 0); + } + + function test_initialize_admin_may_equal_owner() public { + // admin == owner is a + // supported shape (the governance Safe holds both roles at launch). + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, OWNER, address(wrbtc), MIN_DELAY, s + ); + ExitDelayQueue q = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + assertEq(q.owner(), OWNER); + assertEq(q.admin(), OWNER); + } + + function test_initialize_reverts_zero_wrbtc() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = + abi.encodeWithSelector(ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(0), MIN_DELAY, s); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + new ERC1967Proxy(address(impl), init); + } + + function test_constructor_disables_initializers() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + vm.expectRevert(); + impl.initialize(OWNER, ADMIN, address(wrbtc), MIN_DELAY, s); + } + + function test_renounceOwnership_disabled() public { + vm.prank(OWNER); + vm.expectRevert(ExitDelayQueue.OwnershipCannotBeRenounced.selector); + queue.renounceOwnership(); + } + + // ── ingress: ERC20 pull ── + + function test_recordERC20_happy() public { + uint128 amount = 100 ether; + uint256 id = _queueErc20(amount); + assertEq(id, 1); + + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertEq(r.amount, amount); + assertEq(r.originator, ORIG); + assertEq(r.owner, OWNR); + assertEq(r.receiver, RCVR); + assertEq(r.token, address(token)); + assertEq(uint256(r.status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(r.unlockAt, uint64(block.timestamp + DELAY)); + assertEq(r.createdAt, uint64(block.timestamp)); + assertEq(queue.totalEscrowed(address(token)), amount); + assertEq(token.balanceOf(address(queue)), amount); + } + + function test_recordERC20_reverts_unregistered_source() public { + token.mint(OUTSIDER, 10 ether); + vm.startPrank(OUTSIDER); + token.approve(address(queue), 10 ether); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnregisteredSource.selector, OUTSIDER)); + queue.recordERC20Exit(address(token), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + vm.stopPrank(); + } + + function test_recordERC20_reverts_below_floor() public { + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.DelayBelowFloor.selector, uint32(MIN_DELAY - 1), MIN_DELAY) + ); + source.recordERC20( + address(token), 1 ether, MIN_DELAY - 1, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false + ); + } + + function test_recordERC20_reverts_zero_amount() public { + vm.expectRevert(IExitDelayQueue.ZeroAmount.selector); + source.recordERC20(address(token), 0, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_recordERC20_reverts_zero_party() public { + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + source.recordERC20(address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, address(0), OWNR, RCVR, false); + } + + function test_recordERC20_fee_on_transfer_rejected() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(); + fot.mint(address(source), 100 ether); + vm.expectRevert(); // ReceivedAmountMismatch + source.recordERC20(address(fot), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_recordERC20_unwrap_flag_requires_wrbtc() public { + vm.expectRevert(IExitDelayQueue.UnwrapNonWrbtc.selector); + source.recordERC20( + address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, /*unwrap=*/ true + ); + } + + function test_recordERC20_wrbtc_unwrap_ok() public { + uint256 id = + source.recordERC20(address(wrbtc), 5 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertTrue(r.unwrapOnDelivery); + assertEq(r.token, address(wrbtc)); + } + + // ── ingress: measured-delta ERC20 ── + + function test_recordReceivedERC20_happy() public { + uint256 id = + source.recordReceivedERC20(address(token), 50 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR); + assertEq(id, 1); + assertEq(queue.totalEscrowed(address(token)), 50 ether); + } + + function test_recordReceivedERC20_reverts_on_short_push() public { + // push only 40 but claim 50 → delta mismatch + vm.prank(address(source)); + token.transfer(address(queue), 40 ether); + vm.prank(address(source)); + vm.expectRevert(); + queue.recordReceivedERC20Exit(address(token), 50 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR); + } + + // ── ingress: native ── + + function test_recordNative_happy() public { + uint256 id = + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + assertEq(queue.totalEscrowed(address(0)), 3 ether); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + assertEq(r.token, address(0)); + assertEq(address(queue).balance, 3 ether); + } + + function test_recordNative_reverts_value_mismatch() public { + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AmountMismatch.selector, 2 ether, 3 ether)); + source.recordNative{value: 2 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + } + + /// @dev `receive` is now UNCONDITIONAL — it accepts + /// native from ANYONE with no sender gate. Stray/donated RBTC only accrues + /// as sweepable surplus (never mis-credited, per the), and the gate + /// had to go because a storage-reading receive() OutOfGas-bricks the + /// 2300-stipend WRBTC unwrap (see ExitDelayQueueUnwrapStipend.t.sol). + function test_receive_accepts_from_anyone_unconditional() public { + vm.deal(OUTSIDER, 1 ether); + vm.prank(OUTSIDER); + (bool ok,) = payable(address(queue)).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(queue).balance, 1 ether); + // The donated native is sweepable surplus, not backing (totalEscrowed==0). + assertEq(queue.totalEscrowed(address(0)), 0); + address to = address(0x5EEE); + vm.prank(OWNER); + queue.sweepSurplus(address(0), to); + assertEq(to.balance, 1 ether); + assertEq(address(queue).balance, 0); + } + + function test_recordReceivedNative_via_pusher() public { + vm.prank(OWNER); + queue.setNativePusher(address(pusher)); + // register the source that does the record (a source can be the pusher's + // caller in the real flow; here we register `source` and have IT record). + // Push 4 ether into the queue via the pusher, then record from source. + pusher.push(payable(address(queue)), 4 ether); + uint256 id = source.recordReceivedNative(4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + assertEq(queue.totalEscrowed(address(0)), 4 ether); + assertEq(id, 1); + } + + // ── execution ── + + function test_executeExit_by_owner_after_unlock() public { + uint128 amount = 100 ether; + _queueErc20(amount); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = token.balanceOf(RCVR); + vm.prank(OWNR); + queue.executeExit(1); + assertEq(token.balanceOf(RCVR), rcvrBefore + amount); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + function test_executeExit_by_originator() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ORIG); + queue.executeExit(1); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_executeExit_reverts_receiver_not_executor() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(RCVR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, RCVR)); + queue.executeExit(1); + } + + function test_executeExit_reverts_before_unlock() public { + _queueErc20(10 ether); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.NotUnlocked.selector, 1, uint64(block.timestamp + DELAY)) + ); + queue.executeExit(1); + } + + function test_executeExit_at_exact_unlock_ok() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); // inclusive boundary + vm.prank(OWNR); + queue.executeExit(1); // must not revert + } + + function test_executeExit_reverts_unknown() public { + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.executeExit(99); + } + + function test_executeExit_reverts_double_execute() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.executeExit(1); + } + + function test_executeExit_reverts_when_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.QueuePaused.selector); + queue.executeExit(1); + } + + function test_executeExit_native_pays_receiver() public { + source.recordNative{value: 5 ether}(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + vm.prank(OWNR); + queue.executeExit(1); + assertEq(RCVR.balance, before + 5 ether); + } + + function test_executeExit_wrbtc_unwraps_to_native() public { + uint256 id = + source.recordERC20(address(wrbtc), 5 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + vm.prank(OWNR); + queue.executeExit(id); + assertEq(RCVR.balance, before + 5 ether); // native received, not WRBTC + assertEq(wrbtc.balanceOf(RCVR), 0); + } + + function test_executeExit_reverting_receiver_holds_request() public { + RevertingReceiver rr = new RevertingReceiver(); + source.recordNative{value: 1 ether}(1 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr)); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(); // Address.sendValue bubbles + queue.executeExit(1); + // still Queued (whole call rolled back) → recoverable via Leg-3 + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + // ── batch execution ── + + function test_executeExits_batch_happy() public { + _queueErc20(1 ether); + _queueErc20(2 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 2; + vm.prank(OWNR); + queue.executeExits(ids); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(uint256(queue.getRequest(2).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_executeExits_reverts_whole_batch_on_invalid() public { + _queueErc20(1 ether); + _queueErc20(2 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 99; // unknown → whole batch reverts + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.executeExits(ids); + // id 1 must NOT have executed (atomicity) + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + function test_executeExits_duplicate_id_reverts() public { + _queueErc20(1 ether); + vm.warp(block.timestamp + DELAY); + uint256[] memory ids = new uint256[](2); + ids[0] = 1; + ids[1] = 1; // duplicate → second hits AlreadyTerminal + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.executeExits(ids); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + function test_executeExits_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.executeExits(ids); + } + + // ── block model ── + + function test_freeze_blocks_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ORIG, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.executeExit(1); + } + + function test_freeze_receiver_blocks_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(RCVR); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.executeExit(1); + } + + function test_unfreeze_restores_execution() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(ADMIN); + queue.unfreeze(ORIG); + vm.prank(OWNR); + queue.executeExit(1); // executes fine now + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + function test_freeze_only_admin_or_owner() public { + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.freeze(ORIG); + } + + function test_blacklist_escalates_from_frozen_atomically() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + vm.prank(ADMIN); + queue.blacklist(ORIG); // no unfreeze first + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_unblacklist_on_frozen_reverts() public { + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotBlacklisted.selector, ORIG)); + queue.unblacklist(ORIG); + } + + function test_unfreeze_on_blacklisted_reverts() public { + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotFrozen.selector, ORIG)); + queue.unfreeze(ORIG); + } + + function test_unfreeze_absent_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotFrozen.selector, ORIG)); + queue.unfreeze(ORIG); + } + + function test_freeze_on_blacklisted_no_downgrade() public { + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(ADMIN); + queue.freeze(ORIG); // must NOT downgrade + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_freezeFromRequest_blocks_orig_and_owner() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freezeFromRequest(1, false, bytes32(0)); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.None)); // freezeReceiver=false + assertEq(queue.blockTrigger(ORIG), 1); + } + + function test_freezeFromRequest_receiver_flag() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freezeFromRequest(1, true, bytes32(0)); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + function test_blockedAccounts_enumeration() public { + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + (address[] memory got, uint256 total) = queue.blockedAccounts(0, 10); + assertEq(got.length, 2); + assertEq(total, 2); + } + + function test_batch_freeze() public { + address[] memory a = new address[](2); + a[0] = ORIG; + a[1] = OWNR; + vm.prank(ADMIN); + queue.freeze(a); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + // ── recovery: Leg 2 ── + + function _setupRoute(bool topUp) internal returns (bytes32 routeId) { + if (topUp) { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + } + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: topUp ? SUBPRODUCT : address(0xDE57), + topUpPool: topUp + }); + vm.prank(OWNER); + routeId = queue.setRecoveryRoute(route); + } + + function test_resolveToProtocol_requires_blacklisted_source() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + // no blacklist yet → SourceNotBlacklisted + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.SourceNotBlacklisted.selector, ORIG)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_happy_on_blacklisted_originator() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(ORIG); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + uint256 destBefore = token.balanceOf(address(0xDE57)); + vm.prank(ADMIN); + queue.resolveToProtocol(ids, routeId); + assertEq(token.balanceOf(address(0xDE57)), destBefore + 10 ether); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedToProtocol)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + function test_resolveToProtocol_authorized_by_owner_blacklist() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(OWNR); // owner blacklisted (OR predicate) + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + queue.resolveToProtocol(ids, routeId); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedToProtocol)); + } + + function test_resolveToProtocol_receiver_block_does_not_authorize() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(RCVR); // receiver-only → NEVER authorizes Leg-2 + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.SourceNotBlacklisted.selector, ORIG)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_provenance_mismatch() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklist(ORIG); + // route with a different token + MockERC20 other = new MockERC20(); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(other), + destination: address(0xDE57), + topUpPool: false + }); + vm.prank(OWNER); + bytes32 routeId = queue.setRecoveryRoute(route); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteProvenanceMismatch.selector, 1, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_setRecoveryRoute_topup_requires_feasible() public { + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: SUBPRODUCT, + topUpPool: true + }); + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.TopUpInfeasibleSurface.selector, SURFACE)); + queue.setRecoveryRoute(route); + } + + function test_setRecoveryRoute_topup_rejects_native() public { + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE, true); + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(0), // native → can never be Leg-2a + destination: SUBPRODUCT, + topUpPool: true + }); + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.TopUpInfeasibleSurface.selector, SURFACE)); + queue.setRecoveryRoute(route); + } + + function test_resolveToProtocol_only_admin_or_owner() public { + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.resolveToProtocol(ids, routeId); + } + + // ── recovery: Leg 3 ── + + function test_resolveBySIP_on_blocked_request() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.freeze(RCVR); // receiver-only block → held, resolvable by SIP + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + address dest = address(0x7EEA); + vm.prank(OWNER); + queue.resolveBySIP(ids, dest); + assertEq(token.balanceOf(dest), 10 ether); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_on_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + queue.resolveBySIP(ids, address(0x7EEA)); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_on_not_yet_unlocked() public { + _queueErc20(10 ether); + // still locked → resolvable + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + queue.resolveBySIP(ids, address(0x7EEA)); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.ResolvedBySIP)); + } + + function test_resolveBySIP_rejects_honest_unlocked_request() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); // unlocked, unblocked, not paused + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotResolvableBySIP.selector, 1)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_only_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); // admin cannot Leg-3 + vm.expectRevert(); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + // ── recoverStuckExit(id, altReceiver) — verify-by-attempting redirect leg ── + + address constant ALT = address(0x0A17); // healthy alternate receiver (no code) + + /// @notice (a) bouncing original receiver + clean actors → recoverStuckExit + /// attempts the stored receiver (bounces), then pays altReceiver; + /// status Executed, escrow cleared. + function test_recover_bouncing_original_pays_altReceiver() public { + RevertingReceiver rr = new RevertingReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr) + ); + vm.warp(block.timestamp + DELAY); + + // A straight execute bounces (fail-closed payout, request held). + vm.prank(OWNR); + vm.expectRevert(); + queue.executeExit(id); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + + uint256 altBefore = ALT.balance; + // The ExitExecuted event names the party that actually got paid: ALT. + vm.expectEmit(true, true, false, true, address(queue)); + emit ExitExecuted(id, ALT, address(0), 4 ether); + + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); + + assertEq(ALT.balance, altBefore + 4 ether, "alt paid on bounce"); + assertEq(address(rr).balance, 0, "bouncing original got nothing"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(0)), 0); + } + + /// @notice (a') bouncing original for a WRBTC-escrowed (unwrapOnDelivery) + /// request: the stored-receiver unwrap+send bounces, then altReceiver is + /// paid NATIVE RBTC (the unwrap is re-done on the altReceiver leg). + function test_recover_bouncing_original_unwrap_pays_native_alt() public { + RevertingReceiver rr = new RevertingReceiver(); + uint128 amount = 5 ether; + uint256 id = source.recordERC20( + address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, address(rr), true + ); + vm.warp(block.timestamp + DELAY); + + uint256 altBefore = ALT.balance; + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); + + assertEq(ALT.balance, altBefore + amount, "alt gets native (unwrapped)"); + assertEq(queue.totalEscrowed(address(wrbtc)), 0); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @notice (b) HEALTHY original receiver → recoverStuckExit pays the STORED + /// receiver; altReceiver is IGNORED (proves no arbitrary redirect — + /// a healthy exit can never be diverted, verify-by-attempting). + function test_recover_healthy_original_pays_stored_receiver_alt_ignored() public { + uint128 amount = 100 ether; + _queueErc20(amount); // stored receiver = RCVR (healthy, no code) + vm.warp(block.timestamp + DELAY); + + uint256 rcvrBefore = token.balanceOf(RCVR); + uint256 altBefore = token.balanceOf(ALT); + + // Event names the STORED receiver — altReceiver is not the payee. + vm.expectEmit(true, true, false, true, address(queue)); + emit ExitExecuted(1, RCVR, address(token), amount); + + vm.prank(OWNR); + queue.recoverStuckExit(1, ALT); + + assertEq(token.balanceOf(RCVR), rcvrBefore + amount, "stored receiver paid"); + assertEq(token.balanceOf(ALT), altBefore, "altReceiver ignored on a healthy exit"); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(token)), 0); + } + + /// @notice (b') healthy original for a NATIVE request → stored receiver paid, + /// altReceiver ignored. + function test_recover_healthy_native_pays_stored_receiver() public { + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = RCVR.balance; + uint256 altBefore = ALT.balance; + vm.prank(ORIG); + queue.recoverStuckExit(1, ALT); + assertEq(RCVR.balance, rcvrBefore + 3 ether, "stored receiver paid"); + assertEq(ALT.balance, altBefore, "alt ignored"); + } + + /// @notice (b'') altReceiver == stored receiver on a HEALTHY exit pays EXACTLY + /// ONCE (regression for the double-pay the invariant suite caught: a + /// successful stored-receiver attempt must never also fire the + /// altReceiver leg just because the two addresses are equal). + function test_recover_healthy_alt_equals_receiver_pays_once() public { + source.recordNative{value: 3 ether}(3 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.warp(block.timestamp + DELAY); + uint256 rcvrBefore = RCVR.balance; + uint256 queueBefore = address(queue).balance; + vm.prank(OWNR); + queue.recoverStuckExit(1, RCVR); // altReceiver == stored receiver + assertEq(RCVR.balance, rcvrBefore + 3 ether, "paid exactly once"); + assertEq(address(queue).balance, queueBefore - 3 ether, "queue drained exactly once"); + assertEq(queue.totalEscrowed(address(0)), 0); + } + + /// @notice (c) blocked STORED receiver → recoverStuckExit reverts + /// ActorBlocked(receiver) EVEN WITH a clean altReceiver. This is the + /// must-fix regression: a blocked/hacked original receiver refuses + /// recovery entirely (→ Leg-3), so it can never be bypassed by naming a + /// fresh altReceiver. The exact bypass the review caught. + function test_recover_reverts_if_stored_receiver_blocked_even_with_clean_alt() public { + _queueErc20(10 ether); // stored receiver = RCVR + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.blacklist(RCVR); // the STORED receiver is a confirmed-hack address + + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Blacklisted + ) + ); + queue.recoverStuckExit(1, ALT); // clean alt, but stored receiver is blocked + + // Nothing moved — still Queued, escrow intact (falls to Leg-3). + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(token)), 10 ether); + } + + /// @notice (c') a merely-Frozen stored receiver also refuses recovery (both + /// block states gate). + function test_recover_reverts_if_stored_receiver_frozen() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(RCVR); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, RCVR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-alt) blocked altReceiver → reverts ActorBlocked(altReceiver). + function test_recover_reverts_if_altReceiver_frozen() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ALT); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ALT, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + assertEq(uint256(queue.getRequest(1).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + /// @notice (d-alt-bl) blacklisted altReceiver → reverts. + function test_recover_reverts_if_altReceiver_blacklisted() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.blacklist(ALT); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ALT, IExitDelayQueue.BlockState.Blacklisted + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-orig) blocked ORIGINATOR → reverts (a hacked source cannot escape + /// a freeze via the recovery leg). + function test_recover_reverts_if_originator_blocked() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(ORIG); + vm.prank(OWNR); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, ORIG, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (d-owner) blocked OWNER → reverts. + function test_recover_reverts_if_owner_blocked() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.freeze(OWNR); + vm.prank(ORIG); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ActorBlocked.selector, OWNR, IExitDelayQueue.BlockState.Frozen + ) + ); + queue.recoverStuckExit(1, ALT); + } + + /// @notice (e) altReceiver guard: reverts if altReceiver is + /// {0, this, token, wrbtc}. + function test_recover_reverts_if_altReceiver_zero() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(0))); + queue.recoverStuckExit(1, address(0)); + } + + function test_recover_reverts_if_altReceiver_is_queue() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(queue))); + queue.recoverStuckExit(1, address(queue)); + } + + function test_recover_reverts_if_altReceiver_is_token() public { + _queueErc20(10 ether); // request token == token + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(token))); + queue.recoverStuckExit(1, address(token)); + } + + function test_recover_reverts_if_altReceiver_is_wrbtc() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.InvalidAltReceiver.selector, address(wrbtc))); + queue.recoverStuckExit(1, address(wrbtc)); + } + + /// @notice (f) caller not in {originator, owner} → reverts NotExecutor. The + /// receiver may NOT recover (authorization matches executeExit). + function test_recover_reverts_if_caller_not_executor() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + // The stored receiver is not an executor. + vm.prank(RCVR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, RCVR)); + queue.recoverStuckExit(1, ALT); + // Neither is an arbitrary outsider. + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotExecutor.selector, OUTSIDER)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit still enforces the unlock gate. + function test_recover_reverts_if_locked() public { + _queueErc20(10 ether); + uint64 unlockAt = queue.getRequest(1).unlockAt; + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.NotUnlocked.selector, 1, unlockAt)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit still enforces the pause gate. + function test_recover_reverts_if_paused() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + vm.prank(OWNR); + vm.expectRevert(IExitDelayQueue.QueuePaused.selector); + queue.recoverStuckExit(1, ALT); + } + + /// @notice recoverStuckExit reverts UnknownRequest / AlreadyTerminal like execute. + function test_recover_reverts_unknown_and_terminal() public { + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 999)); + queue.recoverStuckExit(999, ALT); + + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); // terminal now (healthy pay to RCVR) + vm.prank(OWNR); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.recoverStuckExit(1, ALT); + } + + /// @notice Both original AND altReceiver bounce → the whole call reverts and the + /// funds stay Queued (CEI rollback; no partial spend). + function test_recover_reverts_if_both_bounce() public { + RevertingReceiver rr1 = new RevertingReceiver(); + RevertingReceiver rr2 = new RevertingReceiver(); + uint256 id = source.recordNative{value: 2 ether}( + 2 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(rr1) + ); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + vm.expectRevert(); // altReceiver (rr2) sendValue reverts → whole call rolls back + queue.recoverStuckExit(id, address(rr2)); + // Still Queued, escrow intact. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), 2 ether); + } + + /// @notice payoutExternal (the internal try/catch trampoline) is self-call-only: + /// a direct external call reverts SelfOnly, so the leg's catchable payout + /// cannot be abused as an arbitrary transfer primitive. + function test_payoutExternal_is_self_only() public { + _queueErc20(10 ether); + vm.prank(OUTSIDER); + vm.expectRevert(IExitDelayQueue.SelfOnly.selector); + queue.payoutExternal(address(token), OUTSIDER, 1 ether, false); + } + + /// @notice Property: recoverStuckExit is solvency-safe and never double-spends + /// across {healthy | bouncing original} × {unlocked | locked} × + /// {altBlocked | clean} × {unpaused | paused}. Exactly one of two + /// outcomes holds: a successful terminal recover (escrow decremented by + /// amount, paid to stored-receiver-if-healthy else altReceiver) or a + /// whole-call revert (nothing changed). + function testFuzz_recover_is_solvency_safe( + bool bouncing, + bool unlocked, + bool altBlocked, + bool paused, + uint128 amount + ) public { + amount = uint128(bound(amount, 1, 1e24)); + + // Stored receiver: either a plain payable EOA (healthy) or a reverting + // contract (bouncing). ORIG/OWNR/ALT are plain payable no-code addrs. + address storedReceiver = bouncing ? address(new RevertingReceiver()) : RCVR; + uint256 id = source.recordNative{value: amount}( + amount, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, storedReceiver + ); + uint256 escrowBefore = queue.totalEscrowed(address(0)); + assertEq(escrowBefore, amount); + + // On a healthy exit the STORED receiver is paid (alt ignored); on a bounce + // the ALT is paid. Either way the payee is a distinct plain address here. + address effReceiver = bouncing ? ALT : storedReceiver; + uint256 effBefore = effReceiver.balance; + + if (unlocked) vm.warp(block.timestamp + DELAY); + if (altBlocked) { + vm.prank(ADMIN); + queue.freeze(ALT); + } + if (paused) { + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); + } + + vm.prank(OWNR); + try queue.recoverStuckExit(id, ALT) { + // Success: terminal, escrow decremented exactly once, paid to effReceiver. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(0)), escrowBefore - amount); + assertEq(effReceiver.balance, effBefore + amount); + // Success requires all gates satisfied. Note: altBlocked always fails the + // gate (altReceiver is checked unconditionally, even on a healthy exit). + assertTrue(unlocked && !paused && !altBlocked, "success needs clean gates"); + } catch { + // Revert: nothing changed, still Queued, escrow intact. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), escrowBefore); + assertTrue(!unlocked || paused || altBlocked, "revert requires a failing gate"); + } + } + + // ── sweepSurplus ── + + function test_sweepSurplus_moves_only_surplus() public { + _queueErc20(10 ether); // escrowed backing = 10 + // force-send 3 ether of dust + token.mint(address(queue), 3 ether); + uint256 destBefore = token.balanceOf(OWNER); + vm.prank(OWNER); + queue.sweepSurplus(address(token), OWNER); + assertEq(token.balanceOf(OWNER), destBefore + 3 ether); // only surplus + assertEq(token.balanceOf(address(queue)), 10 ether); // backing intact + assertEq(queue.totalEscrowed(address(token)), 10 ether); + } + + function test_sweepSurplus_native() public { + source.recordNative{value: 5 ether}(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + vm.deal(address(queue), address(queue).balance + 2 ether); // dust + uint256 destBefore = address(0x5EE).balance; + vm.prank(OWNER); + queue.sweepSurplus(address(0), address(0x5EE)); + assertEq(address(0x5EE).balance, destBefore + 2 ether); + assertEq(address(queue).balance, 5 ether); + } + + function test_sweepSurplus_no_surplus_noop() public { + _queueErc20(10 ether); + vm.prank(OWNER); + queue.sweepSurplus(address(token), OWNER); // 0 surplus, must not revert + assertEq(token.balanceOf(address(queue)), 10 ether); + } + + function test_sweepSurplus_only_owner() public { + vm.prank(ADMIN); + vm.expectRevert(); + queue.sweepSurplus(address(token), ADMIN); + } + + // ── active index / views ── + + function test_getActive_pagination() public { + _queueErc20(1 ether); + _queueErc20(1 ether); + _queueErc20(1 ether); + (uint256[] memory ids, uint256 next) = queue.getActive(OWNR, 0, 2); + assertEq(ids.length, 2); + assertEq(next, 2); + (uint256[] memory ids2, uint256 next2) = queue.getActive(OWNR, 2, 2); + assertEq(ids2.length, 1); + assertEq(next2, 0); // end + } + + function test_getActive_removed_on_execute() public { + _queueErc20(1 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + (uint256[] memory ids,) = queue.getActive(OWNR, 0, 10); + assertEq(ids.length, 0); + (uint256[] memory ids2,) = queue.getActive(ORIG, 0, 10); + assertEq(ids2.length, 0); // removed from BOTH sets + } + + function test_getActive_dual_key_dedup_when_equal() public { + // originator == owner → single set entry + source.recordERC20(address(token), 1 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, ORIG, RCVR, false); + (uint256[] memory ids,) = queue.getActive(ORIG, 0, 10); + assertEq(ids.length, 1); + } + + function test_freeze_does_not_remove_from_active() public { + _queueErc20(1 ether); + vm.prank(ADMIN); + queue.freeze(ORIG); + (uint256[] memory ids,) = queue.getActive(ORIG, 0, 10); + assertEq(ids.length, 1); // freeze holds, does not remove + } + + // ── config ── + + function test_addAllowedSource_only_owner() public { + vm.prank(OUTSIDER); + vm.expectRevert(); + queue.addAllowedSource(OUTSIDER); + } + + function test_removeAllowedSource() public { + vm.prank(OWNER); + queue.removeAllowedSource(address(source)); + assertFalse(queue.isAllowedSource(address(source))); + } + + function test_setAdmin_may_equal_owner() public { + // Admin may equal Owner. + vm.prank(OWNER); + queue.setAdmin(OWNER); + assertEq(queue.admin(), OWNER); + } + + function test_setMinimumDelaySeconds() public { + vm.prank(OWNER); + queue.setMinimumDelaySeconds(3 hours); + assertEq(queue.minimumDelaySeconds(), 3 hours); + } + + /// @dev C3 / (creation-time): the floor is enforced ONCE, at record time. + /// Raising minimumDelaySeconds after a request is already Queued must NOT + /// retroactively extend that request's unlockAt — the raise governs only + /// NEW requests. The already-Queued exit remains executable at its + /// original unlockAt even though its (unlockAt − createdAt) is now BELOW + /// the raised live floor. + function test_floorRaise_does_not_extend_queued_request() public { + // Queue with DELAY (2h) under the initial 1h floor. + uint256 id = _queueErc20(10 ether); + IExitDelayQueue.ExitRequest memory r = queue.getRequest(id); + uint64 unlockAtBefore = r.unlockAt; + assertEq(uint256(r.unlockAt) - uint256(r.createdAt), DELAY); + + // Raise the floor to 10h — ABOVE this request's 2h span. + vm.prank(OWNER); + queue.setMinimumDelaySeconds(10 hours); + assertEq(queue.minimumDelaySeconds(), 10 hours); + + // The stored unlockAt is unchanged (immutable post-record). + IExitDelayQueue.ExitRequest memory r2 = queue.getRequest(id); + assertEq(r2.unlockAt, unlockAtBefore); + assertLt(uint256(r2.unlockAt) - uint256(r2.createdAt), queue.minimumDelaySeconds()); + + // And it is still executable at its ORIGINAL unlockAt (the raise did not + // push the gate out). Warp to the original unlock and execute. + vm.warp(unlockAtBefore); + uint256 balBefore = token.balanceOf(RCVR); + vm.prank(ORIG); + queue.executeExit(id); + assertEq(token.balanceOf(RCVR) - balBefore, 10 ether); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @dev Complement to the above: a NEW request recorded AFTER the raise IS + /// subject to the new floor (DelayBelowFloor on a sub-floor delay). + function test_floorRaise_applies_to_new_requests() public { + vm.prank(OWNER); + queue.setMinimumDelaySeconds(10 hours); + // DELAY (2h) is now below the 10h floor → the new record is rejected. + vm.expectRevert( + abi.encodeWithSelector(IExitDelayQueue.DelayBelowFloor.selector, DELAY, uint32(10 hours)) + ); + vm.prank(address(this)); + source.recordERC20(address(token), 10 ether, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false); + } + + function test_setSecurityPerimeterPaused_authority() public { + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.setSecurityPerimeterPaused(true); + } + + // ── batch by-request-id block variants ── + + /// @dev Queue a request with an explicit {orig, owner, receiver} so a batch can + /// span multiple distinct parties. Returns the new id. + function _queueErc20With(uint128 amount, address orig, address ownr, address rcvr) + internal + returns (uint256 id) + { + vm.prank(address(this)); + id = source.recordERC20(address(token), amount, DELAY, SURFACE, SUBPRODUCT, orig, ownr, rcvr, false); + } + + function test_freezeFromRequest_batch_blocks_all_parties() public { + // two requests, four distinct parties (orig/owner each) plus receivers. + address o1 = address(0xA100); + address w1 = address(0xA101); + address o2 = address(0xA200); + address w2 = address(0xA201); + uint256 id1 = _queueErc20With(10 ether, o1, w1, address(0xA1CE)); + uint256 id2 = _queueErc20With(11 ether, o2, w2, address(0xA2CE)); + + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = id2; + vm.prank(ADMIN); + queue.freezeFromRequest(ids, false, keccak256("case-1")); + + // all four source parties are frozen in ONE tx. + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(w1)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(o2)), uint256(IExitDelayQueue.BlockState.Frozen)); + assertEq(uint256(queue.blockStateOf(w2)), uint256(IExitDelayQueue.BlockState.Frozen)); + // receivers untouched (freezeReceiver=false); trigger links to the id. + assertEq(uint256(queue.blockStateOf(address(0xA1CE))), uint256(IExitDelayQueue.BlockState.None)); + assertEq(queue.blockTrigger(o1), id1); + assertEq(queue.blockTrigger(o2), id2); + } + + function test_freezeFromRequest_batch_freezeReceiver_true() public { + uint256 id1 = _queueErc20With(10 ether, address(0xB100), address(0xB101), address(0xB1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.freezeFromRequest(ids, true, bytes32(0)); + assertEq(uint256(queue.blockStateOf(address(0xB1CE))), uint256(IExitDelayQueue.BlockState.Frozen)); + } + + function test_blacklistFromRequest_batch_blocks_all_parties() public { + uint256 id1 = _queueErc20With(10 ether, address(0xC100), address(0xC101), address(0xC1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.blacklistFromRequest(ids, false, bytes32(0)); + assertEq( + uint256(queue.blockStateOf(address(0xC100))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + assertEq( + uint256(queue.blockStateOf(address(0xC101))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + } + + /// @dev One unknown id reverts the WHOLE batch (atomic, like executeExits) — + /// no party from the valid id is left blocked. + function test_freezeFromRequest_batch_one_bad_id_reverts_whole_batch() public { + uint256 id1 = _queueErc20With(10 ether, address(0xD100), address(0xD101), address(0xD1CE)); + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = 999; // never recorded + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 999)); + queue.freezeFromRequest(ids, false, bytes32(0)); + // valid id's parties are NOT blocked (whole batch rolled back). + assertEq(uint256(queue.blockStateOf(address(0xD100))), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(address(0xD101))), uint256(IExitDelayQueue.BlockState.None)); + } + + /// @dev Frozen→Blacklisted escalation WITHIN a batch: a party frozen by an + /// earlier op is escalated to Blacklisted by a later blacklist batch that + /// names its request (atomic escalation, last-write-wins trigger). + function test_blacklistFromRequest_batch_escalates_frozen_party() public { + address o1 = address(0xE100); + address w1 = address(0xE101); + uint256 id1 = _queueErc20With(10 ether, o1, w1, address(0xE1CE)); + + // first freeze via the single-id path. + vm.prank(ADMIN); + queue.freezeFromRequest(id1, false, keccak256("first")); + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Frozen)); + + // now a batch blacklist over the same request escalates directly. + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + vm.prank(ADMIN); + queue.blacklistFromRequest(ids, false, keccak256("confirmed")); + assertEq(uint256(queue.blockStateOf(o1)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(w1)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_freezeFromRequest_batch_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.freezeFromRequest(ids, false, bytes32(0)); + } + + function test_blacklistFromRequest_batch_empty_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.blacklistFromRequest(ids, false, bytes32(0)); + } + + function test_freezeFromRequest_batch_only_admin_or_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.freezeFromRequest(ids, false, bytes32(0)); + } + + function test_blacklistFromRequest_batch_by_owner_allowed() public { + uint256 id1 = _queueErc20With(10 ether, address(0xF100), address(0xF101), address(0xF1CE)); + uint256[] memory ids = new uint256[](1); + ids[0] = id1; + // Owner (not just Admin) may also block (onlyAdminOrOwner). + vm.prank(OWNER); + queue.blacklistFromRequest(ids, false, bytes32(0)); + assertEq( + uint256(queue.blockStateOf(address(0xF100))), uint256(IExitDelayQueue.BlockState.Blacklisted) + ); + } + + // ── Admin == Owner supported (chokepoint retired) ── + + /// @dev The 2-step handoff to the current Admin now succeeds and merges the + /// roles — a deliberate decision (launch shape: governance Safe holds + /// both). While merged, the Leg-2/Leg-3 split is intentionally + /// vacuous; it becomes real when ownership moves to Bitocracy. + function test_transferOwnership_to_admin_then_accept_merges_roles() public { + vm.prank(OWNER); + queue.transferOwnership(ADMIN); // pending only; no merge yet + assertEq(queue.owner(), OWNER); // still the old owner + + vm.prank(ADMIN); + queue.acceptOwnership(); + assertEq(queue.owner(), ADMIN, "roles merged: admin is now owner"); + assertEq(queue.admin(), ADMIN, "admin unchanged"); + } + + // ── record* are nonReentrant ── + + function test_recordERC20_is_nonReentrant() public { + ReentrantERC20 rnt = new ReentrantERC20(); + rnt.setQueue(queue); + // Register two sources so the ONLY remaining revert cause on the inner + // re-entrant call is the ReentrancyGuard: + // - address(this): the source that pulls rnt and calls recordERC20Exit + // (the outer call); + // - address(rnt): the source the re-entrant inner call passes as + // msg.sender (rnt._transfer re-enters, so msg.sender == rnt). Without + // this, the inner call would revert on the source-authorization check + // even with nonReentrant removed, masking the guard. + vm.startPrank(OWNER); + queue.addAllowedSource(address(this)); // this test contract is the source + queue.addAllowedSource(address(rnt)); // rnt is the inner re-entrant caller + vm.stopPrank(); + rnt.mint(address(this), 1000 ether); + rnt.approve(address(queue), type(uint256).max); + rnt.arm(); // next transferFrom re-enters the queue + + // outer record triggers the pull → rnt._transfer re-enters recordERC20Exit. + queue.recordERC20Exit( + address(rnt), uint128(5 ether), DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, false + ); + // the outer call succeeded; the re-entrant inner call was attempted AND + // reverted (nonReentrant tripped). + assertTrue(rnt.reentered(), "reentrancy path not exercised"); + assertTrue(rnt.reentryReverted(), "nonReentrant did not block the re-entrant record"); + // Assert the revert was SPECIFICALLY the ReentrancyGuard, not some other + // require. This is the exact OZ 4.9.6 require string + // (lib/openzeppelin-contracts-upgradeable ReentrancyGuardUpgradeable.sol). + assertEq( + rnt.reentryRevertReason(), "ReentrancyGuard: reentrant call", "revert was not the ReentrancyGuard" + ); + } + + // ── blockedAccounts page cap / no overflow-revert ── + + function test_blockedAccounts_large_offset_and_limit_no_overflow() public { + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + // A near-max offset+limit used to overflow-revert (unchecked add). Now the + // limit is capped and offset>=len returns empty — never reverts. `total` + // is still the true set size even when the page is empty. + (address[] memory got, uint256 total) = queue.blockedAccounts(type(uint256).max, type(uint256).max); + assertEq(got.length, 0); + assertEq(total, 2); + } + + function test_blockedAccounts_limit_capped_to_page_max() public { + // With only 2 blocked accounts and a huge limit, we get exactly 2 back and + // no overflow (limit clamped to MAX_GET_ACTIVE_PAGE internally). + vm.startPrank(ADMIN); + queue.freeze(ORIG); + queue.blacklist(OWNR); + vm.stopPrank(); + (address[] memory got, uint256 total) = queue.blockedAccounts(0, type(uint256).max); + assertEq(got.length, 2); + assertEq(total, 2); + } + + /// @dev `total` reports the FULL blocked-set size even when a + /// caller pages past the 500-entry cap — so a monitor never silently + /// undercounts. Block 600 accounts, then read page 0 with a huge limit: + /// page is clamped to 500 but `total` reads back the true 600. + function test_blockedAccounts_total_reports_full_size_past_page_cap() public { + uint256 n = 600; + address[] memory many = new address[](n); + for (uint256 i = 0; i < n; ++i) { + // start at 1 so no address is 0 (ZeroAddress guard in _setBlock) + many[i] = address(uint160(i + 1)); + } + vm.prank(ADMIN); + queue.freeze(many); + + (address[] memory page, uint256 total) = queue.blockedAccounts(0, type(uint256).max); + assertEq(page.length, 500); // clamped to MAX_GET_ACTIVE_PAGE + assertEq(total, n); // but total reads back the full 600 + + // The tail past the cap is reachable by advancing the offset; total is + // invariant across pages. + (address[] memory page2, uint256 total2) = queue.blockedAccounts(500, 500); + assertEq(page2.length, 100); + assertEq(total2, n); + } + + // ───────────────────────────────────────────────────────────────────── + // Coverage-gap fill (previous-cycle report): uncovered guard/revert arms + // and 0-hit fns. Fund-relevant Leg-2/Leg-3/solvency/ingress paths first. + // ───────────────────────────────────────────────────────────────────── + + // ── single-id blacklistFromRequest (was 0 hits; only batch tested) ── + + function test_blacklistFromRequest_single_blocks_orig_and_owner() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklistFromRequest(uint256(1), false, keccak256("bad")); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + // receiver NOT blocked (freezeReceiver == false) + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(queue.blockTrigger(ORIG), 1); + } + + function test_blacklistFromRequest_single_freezeReceiver_true() public { + _queueErc20(10 ether); + vm.prank(ADMIN); + queue.blacklistFromRequest(uint256(1), true, keccak256("bad")); + assertEq(uint256(queue.blockStateOf(RCVR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_blacklistFromRequest_single_unknown_id_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.blacklistFromRequest(uint256(99), false, bytes32(0)); + } + + // ── batch address-list variants (blacklist/unfreeze/unblacklist: 0 hits) ── + + function test_batch_blacklist_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.prank(ADMIN); + queue.blacklist(who); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + } + + function test_batch_unfreeze_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.startPrank(ADMIN); + queue.freeze(who); + queue.unfreeze(who); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.None)); + } + + function test_batch_unblacklist_address_list() public { + address[] memory who = new address[](2); + who[0] = ORIG; + who[1] = OWNR; + vm.startPrank(ADMIN); + queue.blacklist(who); + queue.unblacklist(who); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.None)); + assertEq(uint256(queue.blockStateOf(OWNR)), uint256(IExitDelayQueue.BlockState.None)); + } + + // ── EmptyIds on the four address-array block fns ── + + function test_freeze_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.freeze(who); + } + + function test_blacklist_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.blacklist(who); + } + + function test_unfreeze_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.unfreeze(who); + } + + function test_unblacklist_address_batch_empty_reverts() public { + address[] memory who = new address[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.unblacklist(who); + } + + // ── MAX_GET_ACTIVE_PAGE is a public constant ── + + function test_MAX_GET_ACTIVE_PAGE_public_constant() public view { + assertEq(queue.MAX_GET_ACTIVE_PAGE(), 500); + // Reachable through the interface type too (self-describing on-chain). + assertEq(IExitDelayQueue(address(queue)).MAX_GET_ACTIVE_PAGE(), 500); + } + + // ── batch blacklistFromRequest authority arm (only freeze variant tested) ── + + function test_blacklistFromRequest_batch_only_admin_or_owner() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OUTSIDER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.NotAdminOrOwner.selector, OUTSIDER)); + queue.blacklistFromRequest(ids, false, bytes32(0)); + } + + // ── _setBlock ZeroAddress guard (freeze/blacklist address(0)) ── + + function test_freeze_zero_address_reverts() public { + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.freeze(address(0)); + } + + // ── recordReceivedNativeExit short-push mismatch (native twin of ERC20) ── + + function test_recordReceivedNative_reverts_on_short_push() public { + vm.prank(OWNER); + queue.setNativePusher(address(pusher)); + // Push only 3 ether but claim 5 → measured delta (3) < amount (5). + pusher.push(payable(address(queue)), 3 ether); + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, address(0), 3 ether, 5 ether + ) + ); + source.recordReceivedNative(5 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, RCVR); + } + + // ── resolveToProtocol guard arms: EmptyIds / RouteInactive / UnknownRequest / AlreadyTerminal ── + + function test_resolveToProtocol_empty_ids_reverts() public { + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](0); + vm.prank(ADMIN); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_inactive_route_reverts() public { + _queueErc20(10 ether); + // route id that was never registered → route.active == false + bytes32 routeId = keccak256("nonexistent-route"); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteInactive.selector, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_reverts_after_route_removed() public { + // removeRecoveryRoute (0 hits) → the route deactivates → RouteInactive. + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.prank(ADMIN); + queue.blacklist(ORIG); + vm.prank(OWNER); + queue.removeRecoveryRoute(routeId); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.RouteInactive.selector, routeId)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_unknown_request_reverts() public { + bytes32 routeId = _setupRoute(false); + uint256[] memory ids = new uint256[](1); + ids[0] = 99; // never queued + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.resolveToProtocol(ids, routeId); + } + + function test_resolveToProtocol_already_terminal_reverts() public { + // Execute the request first (→ Executed, a terminal status), then try to + // Leg-2 it → AlreadyTerminal. + _queueErc20(10 ether); + bytes32 routeId = _setupRoute(false); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + vm.prank(ADMIN); + queue.blacklist(ORIG); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(ADMIN); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.resolveToProtocol(ids, routeId); + } + + // ── resolveBySIP guard arms: EmptyIds / ZeroAddress / UnknownRequest / AlreadyTerminal ── + + function test_resolveBySIP_empty_ids_reverts() public { + uint256[] memory ids = new uint256[](0); + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.EmptyIds.selector); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_zero_destination_reverts() public { + _queueErc20(10 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.resolveBySIP(ids, address(0)); + } + + function test_resolveBySIP_unknown_request_reverts() public { + uint256[] memory ids = new uint256[](1); + ids[0] = 99; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, 99)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + function test_resolveBySIP_already_terminal_reverts() public { + _queueErc20(10 ether); + vm.warp(block.timestamp + DELAY); + vm.prank(OWNR); + queue.executeExit(1); + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + vm.prank(OWNER); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.AlreadyTerminal.selector, 1)); + queue.resolveBySIP(ids, address(0x7EEA)); + } + + // ── sweepSurplus backstop arms: SweepToZero + SolvencyViolated (native & ERC20) ── + + function test_sweepSurplus_to_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.SweepToZero.selector); + queue.sweepSurplus(address(token), address(0)); + } + + // The SolvencyViolated post-sweep checks (766 native / 772 ERC20) are the + // backstop: with correct accounting `surplus == bal - escrowed`, so the + // post-sweep balance lands at exactly `escrowed` and the require passes. + // Here we drive a legitimate full-surplus sweep so the ERC20 require at 772 + // is REACHED and passes (the pass-branch was the uncovered arm). The FAIL + // branch (a token whose transfer drains more than `surplus` from the queue) + // is exercised in ExitDelayQueueGrief.t.sol::test_sweepSurplus_solvency_violated + // with a bespoke over-draining token mock. + function test_sweepSurplus_erc20_reaches_solvency_check() public { + // Queue escrows 10; mint an extra 5 surplus directly to the queue. + _queueErc20(10 ether); + token.mint(address(queue), 5 ether); + uint256 before = token.balanceOf(address(0xBEEF)); + vm.prank(OWNER); + queue.sweepSurplus(address(token), address(0xBEEF)); + // exactly the 5 surplus moved; escrow backing (10) untouched → check passed + assertEq(token.balanceOf(address(0xBEEF)), before + 5 ether); + assertEq(token.balanceOf(address(queue)), 10 ether); + } + + // ── initialize with a non-empty initialAllowedSources (loop + guards) ── + + function test_initialize_with_initial_sources_and_dup() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address a = address(0xA1); + address b = address(0xB2); + address[] memory s = new address[](3); + s[0] = a; + s[1] = b; + s[2] = a; // duplicate → set.add returns false → skip branch (line 210) + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, s + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + ExitDelayQueue q = ExitDelayQueue(payable(address(proxy))); + assertTrue(q.isAllowedSource(a)); + assertTrue(q.isAllowedSource(b)); + assertEq(q.allowedSources().length, 2); // dup collapsed + } + + function test_initialize_reverts_zero_source() public { + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](1); + s[0] = address(0); // ZeroAddress-source revert (line 209) + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, s + ); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + new ERC1967Proxy(address(impl), init); + } + + // ── _authorizeUpgrade (UUPS): happy upgrade + UpgradeImplZero + only-owner ── + + function test_upgrade_to_v2_by_owner() public { + ExitDelayQueueV2 v2 = new ExitDelayQueueV2(); + vm.prank(OWNER); + queue.upgradeTo(address(v2)); + assertEq(ExitDelayQueueV2(payable(address(queue))).version(), 2); + // state preserved across upgrade + assertEq(queue.owner(), OWNER); + assertEq(queue.admin(), ADMIN); + } + + function test_upgrade_zero_impl_reverts() public { + vm.prank(OWNER); + vm.expectRevert(ExitDelayQueue.UpgradeImplZero.selector); + queue.upgradeTo(address(0)); + } + + function test_upgrade_only_owner() public { + ExitDelayQueueV2 v2 = new ExitDelayQueueV2(); + vm.prank(OUTSIDER); + vm.expectRevert("Ownable: caller is not the owner"); + queue.upgradeTo(address(v2)); + } + + // ── getActive n > MAX_GET_ACTIVE_PAGE cap (the original mirrored) ── + + function test_getActive_n_capped_no_overflow() public { + _queueErc20(1 ether); + _queueErc20(1 ether); + // A near-max `n` used to risk `cursor + n` overflow; it is clamped to the + // page cap and returns only the 2 active ids without reverting. + (uint256[] memory ids, uint256 nextCursor) = queue.getActive(ORIG, 0, type(uint256).max); + assertEq(ids.length, 2); + assertEq(nextCursor, 0); + } + + // ── ZeroAddress guards on setRecoveryRoute / addAllowedSource / setAdmin ── + + function test_setRecoveryRoute_zero_destination_reverts() public { + IExitDelayQueue.RecoveryRoute memory route = IExitDelayQueue.RecoveryRoute({ + active: true, + surfaceId: SURFACE, + subProduct: SUBPRODUCT, + token: address(token), + destination: address(0), + topUpPool: false + }); + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.setRecoveryRoute(route); + } + + function test_addAllowedSource_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.addAllowedSource(address(0)); + } + + function test_setAdmin_zero_reverts() public { + vm.prank(OWNER); + vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + queue.setAdmin(address(0)); + } + + // ── view getters (read-only, low risk): exercise the 0-hit accessors ── + + function test_view_getters() public { + bytes32 routeId = _setupRoute(false); + IExitDelayQueue.RecoveryRoute memory got = queue.getRecoveryRoute(routeId); + assertEq(got.destination, address(0xDE57)); + assertTrue(got.active); + + address[] memory srcs = queue.allowedSources(); + assertEq(srcs.length, 1); // the SourceHarness registered in setUp + assertEq(srcs[0], address(source)); + + bytes32[] memory ids = queue.recoveryRouteIds(); + assertEq(ids.length, 1); + assertEq(ids[0], routeId); + + // topUpFeasible getter: false for an un-flagged surface, true after set + assertFalse(queue.topUpFeasible(SURFACE_ZERO)); + vm.prank(OWNER); + queue.setTopUpFeasible(SURFACE_ZERO, true); + assertTrue(queue.topUpFeasible(SURFACE_ZERO)); + } +} diff --git a/test/unit/ExitDelayQueueGrief.t.sol b/test/unit/ExitDelayQueueGrief.t.sol new file mode 100644 index 0000000..0f21ccc --- /dev/null +++ b/test/unit/ExitDelayQueueGrief.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +/// @dev regression suite . +/// The measured-delta ingress fns credit EXACTLY `amount` when the current +/// non-backing surplus `delta = balanceOf/balance − totalEscrowed` is +/// `>= amount`, and revert `ReceivedAmountMismatch` ONLY when `delta < amount`. +/// This file BOTH captures the original donation-grief PoC (now proving it is +/// fixed) AND adds positive/negative coverage for the new `>= amount` rule. + +contract Tok is ERC20 { + constructor() ERC20("T", "T") {} + + function mint(address a, uint256 v) external { + _mint(a, v); + } +} + +contract WR { + function withdraw(uint256) external {} + receive() external payable {} +} + +/// @dev A hostile token whose `transfer(to, v)` moves `v` to `to` but ALSO burns +/// an extra `drain` from the caller (the queue) in the same call. Under a +/// full-surplus sweep this pushes the queue's post-sweep balance BELOW the +/// escrowed backing, tripping the `SolvencyViolated` post-check (the +/// ERC20 arm at ExitDelayQueue.sol:772). Models a rebasing / hook token +/// that can silently reduce a holder's balance during a transfer. +contract OverDrainToken is ERC20 { + uint256 public drainOnNextTransfer; + + constructor() ERC20("Drain", "DRN") {} + + function mint(address a, uint256 v) external { + _mint(a, v); + } + + function armDrain(uint256 d) external { + drainOnNextTransfer = d; + } + + function transfer(address to, uint256 value) public override returns (bool) { + bool ok = super.transfer(to, value); + uint256 d = drainOnNextTransfer; + if (d != 0) { + drainOnNextTransfer = 0; + // Burn extra from msg.sender (the queue) → balance drops below escrow. + _burn(msg.sender, d); + } + return ok; + } +} + +/// @dev Stand-in for the 0.5.x source that pushes then records in the SAME tx. +contract Src { + ExitDelayQueue q; + + constructor(ExitDelayQueue q_) { + q = q_; + } + /// ERC20: transfer to the queue, then record (measured-delta path). + + function rec(address t, uint128 a, uint32 d, address o) external returns (uint256) { + ERC20(t).transfer(address(q), a); + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, o, o); + } + /// Record WITHOUT pushing enough — used for the under-delivery negative test. + + function recNoPush(address t, uint128 a, uint32 d, address o) external returns (uint256) { + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, o, o); + } + /// Full-arg ERC20 record (distinct originator/owner/receiver) — used by the + /// batch-block tests to create requests spanning many parties. + + function recFull(address t, uint128 a, uint32 d, address o, address w, address r) + external + returns (uint256) + { + ERC20(t).transfer(address(q), a); + return q.recordReceivedERC20Exit(t, a, d, keccak256("S"), address(0), o, w, r); + } + /// Native: forward the msg.value on to the queue, then record. + + function recNative(uint128 a, uint32 d, address o) external returns (uint256) { + (bool ok,) = payable(address(q)).call{value: a}(""); + require(ok, "push failed"); + return q.recordReceivedNativeExit(a, d, keccak256("S"), address(0), o, o, o); + } +} + +/// @dev Force-sends native RBTC to an arbitrary target via selfdestruct — the +/// `receive()` gate cannot stop this, which is the whole point of `>= amount`. +contract ForceSender { + constructor(address payable target) payable { + selfdestruct(target); + } +} + +contract Grief is Test { + ExitDelayQueue q; + Tok tok; + WR wr; + Src src; + + address constant PARTY = address(0x111); + + function setUp() public { + wr = new WR(); + tok = new Tok(); + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory s = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, address(this), address(0xAD), address(wr), uint32(1 hours), s + ); + q = ExitDelayQueue(payable(address(new ERC1967Proxy(address(impl), init)))); + src = new Src(q); + q.addAllowedSource(address(src)); + // The queue must be a registered native pusher target so the Src forward + // (which goes through the queue's receive()) is accepted for native tests. + // ActivePool == the Src here for the native push. + // (addAllowedSource already done; the receive() gate keys on nativePusher.) + tok.mint(address(src), 1000 ether); + } + + // ─── ERC20 measured-delta ─────────────────────────────────────────── + + /// @notice REGRESSION (the original grief PoC, now PROVING the fix): a 1-wei + /// token force-send before a legit same-tx push must NOT brick the + /// record. Under the old `delta == amount` rule this reverted; under + /// the `delta >= amount` rule it records and credits exactly + /// `amount`, leaving the 1-wei as sweepable surplus. + function test_dust_donation_does_not_brick_measured_delta() public { + // Griefer force-sends 1 wei of the token to the queue. + tok.mint(address(this), 1); + tok.transfer(address(q), 1); + + // Legit exit of 50 ether now SUCCEEDS (delta = 50e18 + 1 >= 50e18). + uint256 id = src.rec(address(tok), 50 ether, 2 hours, PARTY); + + // Credited EXACTLY amount — the 1-wei donation is NOT mis-credited. + assertEq(q.totalEscrowed(address(tok)), 50 ether, "credit exactly amount"); + IExitDelayQueue.ExitRequest memory r = q.getRequest(id); + assertEq(r.amount, 50 ether, "request amount"); + assertEq(uint8(r.status), uint8(IExitDelayQueue.ExitStatus.Queued), "queued"); + + // The 1-wei excess remains as non-backing surplus, sweepable by Owner. + assertEq(tok.balanceOf(address(q)), 50 ether + 1, "balance = escrow + donation"); + q.sweepSurplus(address(tok), address(0xBEEF)); + assertEq(tok.balanceOf(address(0xBEEF)), 1, "surplus swept"); + assertEq(tok.balanceOf(address(q)), 50 ether, "backing intact post-sweep"); + assertEq(q.totalEscrowed(address(tok)), 50 ether, "escrow unchanged by sweep"); + } + + /// @notice POSITIVE: a 1-wei donation before a legit push still records and + /// credits exactly amount (the fix-list's explicit positive case). + function test_positive_donation_then_push_credits_exactly_amount() public { + tok.mint(address(this), 1); + tok.transfer(address(q), 1); // donation + + uint256 id = src.rec(address(tok), 10 ether, 3 hours, PARTY); + assertEq(q.totalEscrowed(address(tok)), 10 ether); + assertEq(q.getRequest(id).amount, 10 ether); + } + + /// @notice Repeated records after a donation each consume exactly amount and + /// never re-brick (a donation only raises surplus, so >= still holds). + function test_multiple_records_after_donation() public { + tok.mint(address(this), 5); + tok.transfer(address(q), 5); // 5-wei donation sits as surplus + + src.rec(address(tok), 20 ether, 2 hours, PARTY); + src.rec(address(tok), 30 ether, 2 hours, address(0x222)); + assertEq(q.totalEscrowed(address(tok)), 50 ether, "each record consumed exactly amount"); + assertEq(tok.balanceOf(address(q)), 50 ether + 5, "donation still surplus"); + } + + /// @notice NEGATIVE: an under-delivered push (delta < amount) STILL reverts + /// ReceivedAmountMismatch — the fix relaxes only the upper bound. + function test_under_delivery_still_reverts() public { + // No push at all: delta = 0 < amount. + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, address(tok), uint256(0), uint256(1 ether) + ) + ); + src.recNoPush(address(tok), 1 ether, 2 hours, PARTY); + } + + /// @notice NEGATIVE: partial delivery (a donation smaller than amount) reverts. + function test_partial_delivery_reverts() public { + tok.mint(address(this), 1 ether); + tok.transfer(address(q), 1 ether); // only 1 ether present + vm.expectRevert( + abi.encodeWithSelector( + IExitDelayQueue.ReceivedAmountMismatch.selector, + address(tok), + uint256(1 ether), + uint256(5 ether) + ) + ); + src.recNoPush(address(tok), 5 ether, 2 hours, PARTY); + } + + // ─── Native measured-receipt ──────────────────────────────────────── + + /// @notice REGRESSION (native): a `selfdestruct` force-send of native RBTC + /// (which the `receive()` gate CANNOT block) before a legit push must + /// not brick recordReceivedNativeExit. Credits exactly amount; the + /// force-sent wei stays as sweepable surplus. + function test_native_forcesend_does_not_brick_measured_delta() public { + // Register the Src as the native pusher so the queue's receive() accepts + // the Src's forward. (The Src plays ActivePool here.) + q.setNativePusher(address(src)); + + // Force-send 1 wei via selfdestruct — bypasses receive() entirely. + vm.deal(address(this), 1); + new ForceSender{value: 1}(payable(address(q))); + assertEq(address(q).balance, 1, "force-sent wei present"); + + // Legit native exit of 5 ether now records (delta = 5e18 + 1 >= 5e18). + vm.deal(address(src), 5 ether); + uint256 id = src.recNative(5 ether, 2 hours, PARTY); + + assertEq(q.totalEscrowed(address(0)), 5 ether, "credit exactly amount (native)"); + assertEq(q.getRequest(id).amount, 5 ether); + assertEq(address(q).balance, 5 ether + 1, "balance = escrow + force-send"); + + // Sweep the 1-wei surplus; backing stays intact. + q.sweepSurplus(address(0), address(0xBEEF)); + assertEq(address(q).balance, 5 ether, "backing intact post-sweep"); + } + + // ─── batch by-request-id block — emergency speed lever ───────── + + /// @notice The emergency lever: several known-malicious requests, each opened + /// by a distinct delegate for a distinct owner, are all blocked + /// (owner + delegate) in ONE `blacklistFromRequest(uint256[])` call — + /// the single-Admin-multisig-tx incident response describes. + function test_batch_blacklistFromRequest_blocks_many_parties_one_tx() public { + uint256 id1 = _queueDistinct(1); + uint256 id2 = _queueDistinct(2); + uint256 id3 = _queueDistinct(3); + + uint256[] memory ids = new uint256[](3); + ids[0] = id1; + ids[1] = id2; + ids[2] = id3; + // Admin (the fast guardian) blocks all six parties in one tx. + vm.prank(address(0xAD)); + q.blacklistFromRequest(ids, false, keccak256("incident-42")); + + for (uint160 k = 1; k <= 3; ++k) { + assertEq( + uint8(q.blockStateOf(_orig(k))), uint8(IExitDelayQueue.BlockState.Blacklisted), "orig blocked" + ); + assertEq( + uint8(q.blockStateOf(_ownr(k))), + uint8(IExitDelayQueue.BlockState.Blacklisted), + "owner blocked" + ); + } + } + + /// @notice ATOMICITY grief guard: an operator who accidentally (or a griefer who + /// maliciously) slips ONE unknown id into the batch gets the WHOLE batch + /// reverted — no half-applied block state that would need manual cleanup. + function test_batch_blockFromRequest_is_atomic_on_bad_id() public { + uint256 id1 = _queueDistinct(1); + uint256[] memory ids = new uint256[](2); + ids[0] = id1; + ids[1] = type(uint256).max; // never recorded + vm.prank(address(0xAD)); + vm.expectRevert(abi.encodeWithSelector(IExitDelayQueue.UnknownRequest.selector, type(uint256).max)); + q.freezeFromRequest(ids, false, bytes32(0)); + // id1's parties are untouched — the whole batch rolled back. + assertEq(uint8(q.blockStateOf(_orig(1))), uint8(IExitDelayQueue.BlockState.None)); + assertEq(uint8(q.blockStateOf(_ownr(1))), uint8(IExitDelayQueue.BlockState.None)); + } + + // ─── solvency backstop (SolvencyViolated) ─────────────────────── + + /// @notice The post-sweep solvency require (ERC20 arm, src:772) MUST trip + /// if a hostile/rebasing token drains more than the computed surplus + /// from the queue during `safeTransfer`, dropping backing below the + /// escrowed total. Proves the backstop reverts the whole sweep rather + /// than silently leaking escrowed backing. + function test_sweepSurplus_solvency_violated_erc20() public { + OverDrainToken drn = new OverDrainToken(); + drn.mint(address(src), 1000 ether); + + // Escrow 50 of the drain token via the measured-delta path. + uint256 id = src.rec(address(drn), 50 ether, 2 hours, PARTY); + assertEq(q.totalEscrowed(address(drn)), 50 ether); + assertEq(uint8(q.getRequest(id).status), uint8(IExitDelayQueue.ExitStatus.Queued)); + + // Add 5 surplus so the sweep computes surplus = 5 and calls transfer. + drn.mint(address(q), 5 ether); + // Arm the token to burn an extra 10 from the queue during the transfer — + // post-sweep balance = 50 (escrow) - 10 = 40 < 50 escrowed → revert. + drn.armDrain(10 ether); + + vm.expectRevert(IExitDelayQueue.SolvencyViolated.selector); + q.sweepSurplus(address(drn), address(0xBEEF)); + } + + // ── helpers for the batch-block tests ── + + function _orig(uint160 k) internal pure returns (address) { + return address(0xB0000 + k * 2); + } + + function _ownr(uint160 k) internal pure returns (address) { + return address(0xB0000 + k * 2 + 1); + } + + /// Queue one ERC20 request with distinct originator/owner/receiver derived + /// from `k` via the measured-delta source's full-arg record. + function _queueDistinct(uint160 k) internal returns (uint256 id) { + tok.mint(address(src), 100 ether); + id = src.recFull(address(tok), 10 ether, 2 hours, _orig(k), _ownr(k), address(0xDCE0 + k)); + } +} diff --git a/test/unit/ExitDelayQueueUnwrapStipend.t.sol b/test/unit/ExitDelayQueueUnwrapStipend.t.sol new file mode 100644 index 0000000..fa62db0 --- /dev/null +++ b/test/unit/ExitDelayQueueUnwrapStipend.t.sol @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; + +/// @title ExitDelayQueueUnwrapStipend +/// @notice (HIGH) regression. Reproduces the empirically- +/// proven OutOfGas brick and proves the unconditional `receive()` fix. +/// +/// The real Rootstock WRBTC `withdraw()` returns native RBTC to the caller +/// via a **2300-gas `transfer` stipend** (WETH9 semantics). When the queue +/// unwraps an `unwrapOnDelivery` request at `executeExit`, WRBTC sends the +/// native back into the queue's `receive()`. A `receive()` that reads any +/// storage slot to gate the sender (SLOAD ≥ 2100 cold under EIP-2929 / +/// Paris) exceeds the 2300 stipend and reverts OutOfGas — permanently +/// bricking every native `burnToBTC` (unwrapOnDelivery) payout after unlock. +/// +/// Run under `forge test --isolate` so EIP-2929 cold/warm access gas is +/// charged realistically. The production-path test +/// (`test_unwrap_payout_succeeds_under_transfer_stipend`) passes in BOTH +/// modes and is the mode-independent gate; the apples-to-apples control +/// (`..._control_ISOLATE_ONLY`) reproduces the pre-fix brick only when +/// cold-access gas is charged (isolate) and self-skips otherwise. +contract ExitDelayQueueUnwrapStipendTest is Test { + ExitDelayQueue queue; + WETH9StyleWRBTC wrbtc; + Source source; + + address constant OWNER = address(0x0E1); + address constant ADMIN = address(0xAd11); + address constant ORIG = address(0x0111); + address constant OWNR = address(0x0222); + address payable constant RCVR = payable(address(0x0333)); + + bytes32 constant SURFACE = keccak256("COLFEE:LENDING_LENDER_WITHDRAW"); + address constant SUBPRODUCT = address(0xB00C); + uint32 constant MIN_DELAY = 1 hours; + uint32 constant DELAY = 2 hours; + + function setUp() public { + wrbtc = new WETH9StyleWRBTC(); + + ExitDelayQueue impl = new ExitDelayQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + queue = ExitDelayQueue(payable(address(proxy))); + + source = new Source(queue); + vm.prank(OWNER); + queue.addAllowedSource(address(source)); + + // Fund the source with WRBTC (backed 1:1 by native inside the WRBTC). + wrbtc.depositTo{value: 1_000 ether}(address(source)); + vm.deal(address(this), 1_000 ether); + } + + /// @notice The production fix: with the UNCONDITIONAL receive(), unwrapping a + /// WRBTC-escrowed request via the 2300-stipend WRBTC.withdraw succeeds + /// and the user receives native RBTC. Under `--isolate` the queue's + /// `receive()` slots are cold, so this would OutOfGas-revert if + /// `receive()` did ANY storage read (the pre-fix gate). + function test_unwrap_payout_succeeds_under_transfer_stipend() public { + uint128 amount = 5 ether; + uint256 id = source.record(address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + + vm.warp(block.timestamp + DELAY); + uint256 before = RCVR.balance; + + vm.prank(OWNR); + queue.executeExit(id); + + assertEq(RCVR.balance, before + amount, "receiver got native RBTC"); + assertEq(wrbtc.balanceOf(RCVR), 0, "receiver holds no WRBTC"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + assertEq(queue.totalEscrowed(address(wrbtc)), 0); + } + + /// @notice APPLES-TO-APPLES CONTROL (isolate-only): the identical unwrap-payout + /// flow, but the proxy runs an impl whose `receive()` reads ONE storage + /// slot to gate the sender (the exact pre-fix gate). Under `--isolate` + /// the queue's slots are COLD at the moment WRBTC.withdraw forwards + /// native, so the cold SLOAD (2100 gas, EIP-2929/Paris) on top of the + /// proxy delegatecall exceeds the 2300 `transfer` stipend → OutOfGas, + /// bricking the payout. The SOLE difference from the passing production + /// case above is whether `receive()` touches storage. + /// + /// The brick ONLY reproduces with cold access accounting, i.e. under + /// `forge test --isolate`. Without `--isolate` the queue's slots are + /// warmed earlier in the same tx (warm SLOAD = 100 gas, fits the + /// stipend), so the brick does not manifest and this control self-skips + /// — the production regression above is the real, mode-independent gate. + function test_gated_receive_bricks_unwrap_payout_control_ISOLATE_ONLY() public { + if (!_coldSloadExceedsStipend()) { + emit log("skip: cold-access gas not charged (run with --isolate to exercise this control)"); + return; + } + + // Stand up a second proxy running the sender-GATED impl. + GatedReceiveQueue gatedImpl = new GatedReceiveQueue(); + address[] memory sources = new address[](0); + bytes memory init = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, OWNER, ADMIN, address(wrbtc), MIN_DELAY, sources + ); + ERC1967Proxy gatedProxy = new ERC1967Proxy(address(gatedImpl), init); + ExitDelayQueue gq = ExitDelayQueue(payable(address(gatedProxy))); + + Source gsource = new Source(gq); + vm.prank(OWNER); + gq.addAllowedSource(address(gsource)); + wrbtc.depositTo{value: 100 ether}(address(gsource)); + + uint128 amount = 5 ether; + uint256 id = + gsource.record(address(wrbtc), amount, DELAY, SURFACE, SUBPRODUCT, ORIG, OWNR, RCVR, true); + vm.warp(block.timestamp + DELAY); + + // The gated receive() OutOfGas-reverts the WRBTC unwrap transfer; the whole + // executeExit rolls back (fail-closed), the request stays Queued — the + // permanent brick the fix removes. + vm.prank(OWNR); + vm.expectRevert(); + gq.executeExit(id); + assertEq(uint256(gq.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + } + + /// @dev Detects whether the run charges EIP-2929 cold-access gas (i.e. we are + /// under `--isolate`). Probes a fresh contract's cold storage slot: a cold + /// SLOAD costs ~2100 gas, a warm one ~100. Returns true when the measured + /// cost is high enough that a cold SLOAD would blow the 2300 stipend. + function _coldSloadExceedsStipend() internal returns (bool) { + ColdSlotProbe p = new ColdSlotProbe(); + uint256 used = p.measureColdSload(); + // Cold SLOAD ≈ 2100; warm ≈ 100. Threshold well between the two. + return used > 1500; + } +} + +/// @dev Measures the gas cost of a single (cold) SLOAD in the current run's +/// access-accounting mode. Under `--isolate` a first-touch SLOAD is cold +/// (~2100 gas); otherwise the framework may have warmed it (~100 gas). +contract ColdSlotProbe { + uint256 private slot = 7; + + function measureColdSload() external view returns (uint256 used) { + uint256 g0 = gasleft(); + uint256 v = slot; // the SLOAD under measurement + uint256 g1 = gasleft(); + // touch v so the optimizer cannot elide the load + used = (g0 - g1) + (v & 0); + } +} + +// ─── Mocks ──────────────────────────────────────────────────────────────── + +/// @dev WETH9-style WRBTC: withdraw() forwards native via `transfer` (2300-gas +/// stipend), exactly like the real Rootstock WRBTC. This is the mock that +/// surfaces the brick — a `.call{value}` mock (unlimited gas) would not. +contract WETH9StyleWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + + function deposit() external payable { + _mint(msg.sender, msg.value); + } + + function depositTo(address to) external payable { + _mint(to, msg.value); + } + + function withdraw(uint256 amount) external { + _burn(msg.sender, amount); + // 2300-gas stipend forward — the load-bearing difference from a + // `.call{value: amount}("")` mock. + payable(msg.sender).transfer(amount); + } + + receive() external payable {} +} + +/// @dev Registered ingress source (mirrors an iToken proxy). Pulls WRBTC then +/// records with unwrapOnDelivery. +contract Source { + ExitDelayQueue public queue; + + constructor(ExitDelayQueue q) { + queue = q; + } + + function record( + address token, + uint128 amount, + uint32 d, + bytes32 surfaceId, + address subProduct, + address effOrig, + address effOwner, + address receiver, + bool unwrap + ) external returns (uint256) { + ERC20(token).approve(address(queue), amount); + return queue.recordERC20Exit( + token, amount, d, surfaceId, subProduct, effOrig, effOwner, receiver, unwrap + ); + } +} + +/// @dev Control impl: identical to production ExitDelayQueue EXCEPT `receive()` +/// reads a storage slot to gate the sender (the exact PRE-FIX gate). Deployed +/// behind the same proxy pattern so the ONLY behavioral difference from the +/// production impl is the storage read in `receive()`. Under the WRBTC +/// 2300-gas `transfer` stipend that read OutOfGas-bricks the unwrap payout. +contract GatedReceiveQueue is ExitDelayQueue { + receive() external payable override { + // Cold SLOAD of `wrbtc` (2100 gas under EIP-2929/Paris) + the comparison + // exceeds the 2300 stipend the WRBTC withdraw forwards → OutOfGas. This is + // the empirically-proven brick the unconditional receive() removes. + if (msg.sender != nativePusher && msg.sender != wrbtc) revert UnregisteredSource(msg.sender); + } +} diff --git a/test/unit/ExitFeeController.t.sol b/test/unit/ExitFeeController.t.sol index ffe718c..2866942 100644 --- a/test/unit/ExitFeeController.t.sol +++ b/test/unit/ExitFeeController.t.sol @@ -20,15 +20,14 @@ contract ExitFeeControllerTest is Test { // Re-declared here so vm.expectEmit can match by topic signature. event SubProductPolicyRemoved(bytes32 indexed surfaceId, address indexed subProduct); event ActorPolicyRemoved(bytes32 indexed surfaceId, address indexed actor); - event AdminSet(address indexed admin); ExitFeeController controller; // NOTE: `ADMIN` predates the contract's admin role -- it is the proxy // OWNER throughout this file. The operational guardian stored in - // `ExitFeeController.admin` is `GUARDIAN` below. + // `ExitFeeController.admin` is `GUARDIAN`, declared in the + // delay-extension section below. address constant ADMIN = address(0xA1); - address constant GUARDIAN = address(0xAD); address constant VAULT = address(0xBA); address constant ACTOR = address(0xAC); address constant IXUSD = address(0x1750D); // dummy iToken proxy "iXUSD" @@ -126,19 +125,6 @@ contract ExitFeeControllerTest is Test { assertEq(q.feeAmount, 0); assertEq(q.netAmount, 1_000_000); assertEq(q.reason, uint8(IExitFeeController.SkipReason.DISABLED)); - - // Positive control: flipping ONLY the surface gate on must revive the - // very overrides that were suppressed above. Without this the test - // would pass just as happily against a controller whose fee path was - // dead altogether -- it would prove nothing about the gate. - vm.prank(ADMIN); - controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 20})); - - IExitFeeController.ExitFeeQuote memory qOn = _quote(IWRBTC, 1_000_000); - assertTrue(qOn.active); - assertEq(qOn.rateBps, 5, "actor override applies once the gate is on"); - assertEq(qOn.feeAmount, 500); - assertEq(qOn.reason, uint8(IExitFeeController.SkipReason.NONE)); } // ─── Tier resolution: surface → subProduct → actor ────────────────── @@ -307,23 +293,7 @@ contract ExitFeeControllerTest is Test { uint256 huge = type(uint256).max / 9_999; // would overflow gross * MAX_BPS IExitFeeController.ExitFeeQuote memory q = _quote(IXUSD, huge); assertEq(q.reason, uint8(IExitFeeController.SkipReason.INVALID_QUOTE)); - assertFalse(q.active); - assertEq(q.feeAmount, 0); assertEq(q.netAmount, huge); // synthesized echo of gross - - // Pin the guard's comparison direction at the boundary. `max / MAX_BPS` - // is the largest gross whose `gross * MAX_BPS` still fits, so it MUST - // produce an honest quote; one wei more MUST trip the guard. Without - // both sides an off-by-one (`>` vs `>=`) is invisible. - uint256 edge = type(uint256).max / 10_000; - IExitFeeController.ExitFeeQuote memory qEdge = _quote(IXUSD, edge); - assertTrue(qEdge.active, "gross == max/MAX_BPS must not trip the guard"); - assertEq(qEdge.reason, uint8(IExitFeeController.SkipReason.NONE)); - assertEq(qEdge.feeAmount, (edge * 50) / 10_000); - - IExitFeeController.ExitFeeQuote memory qOver = _quote(IXUSD, edge + 1); - assertFalse(qOver.active, "one wei past the boundary must trip the guard"); - assertEq(qOver.reason, uint8(IExitFeeController.SkipReason.INVALID_QUOTE)); } // ─── Admin / setter validation ─────────────────────────────────────── @@ -340,42 +310,17 @@ contract ExitFeeControllerTest is Test { controller.setExitFeeEnabled(true); } - // ─── Admin role (operational guardian) ─────────────────────────────── + // ─── Admin role: fee levers ──────────────────────────── + // + // setAdmin validation (zero / owner-equals / only-owner / emit) is + // covered in the delay-extension section below (test_setAdmin_*); this + // section pins the core-merge widening: the SAME guardian + // that flips the perimeter kill switch also drives the fee levers. function test_admin_unset_at_init() public view { assertEq(controller.admin(), address(0)); } - function test_setAdmin_sets_and_emits() public { - vm.expectEmit(true, false, false, false, address(controller)); - emit AdminSet(GUARDIAN); - vm.prank(ADMIN); - controller.setAdmin(GUARDIAN); - - assertEq(controller.admin(), GUARDIAN); - } - - function test_setAdmin_non_owner_reverts() public { - // setAdmin stays owner-only -- the guardian cannot appoint itself - // or a successor. - vm.prank(OTHER); - vm.expectRevert("Ownable: caller is not the owner"); - controller.setAdmin(GUARDIAN); - } - - function test_setAdmin_zero_reverts() public { - vm.prank(ADMIN); - vm.expectRevert(ExitFeeController.AdminZero.selector); - controller.setAdmin(address(0)); - } - - function test_setAdmin_may_equal_owner() public { - // admin == owner is a supported shape: one address may hold both roles. - vm.prank(ADMIN); - controller.setAdmin(ADMIN); - assertEq(controller.admin(), ADMIN); - } - function test_admin_can_setExitFeeEnabled_both_directions() public { vm.prank(ADMIN); controller.setAdmin(GUARDIAN); @@ -614,23 +559,12 @@ contract ExitFeeControllerTest is Test { ps[0] = IExitFeeController.RatePolicy({active: true, rateBps: 5}); ps[1] = IExitFeeController.RatePolicy({active: true, rateBps: 10}); controller.setActorPolicies(SURFACE, actors, ps); - // Pin the pre-state: without this the length-0 assertion below would - // also hold if the batch SET had never populated the index. - assertEq(controller.actorKeys(SURFACE).length, 2, "batch set populates the index"); // Removing the same list back drops both keys. controller.removeActorPolicies(SURFACE, actors); vm.stopPrank(); assertEq(controller.actorKeys(SURFACE).length, 0); - - // A hard remove clears the stored RatePolicy too, not just the index - // entry -- otherwise a re-added key would resurrect the old rate. - for (uint256 i = 0; i < actors.length; ++i) { - IExitFeeController.RatePolicy memory p = controller.actorPolicy(SURFACE, actors[i]); - assertFalse(p.active, "stale actor policy left behind"); - assertEq(p.rateBps, 0, "stale actor rate left behind"); - } } function test_remove_address_zero_reverts() public { @@ -684,7 +618,6 @@ contract ExitFeeControllerTest is Test { controller.setFeeReceiver(VAULT); controller.setExitFeeEnabled(true); controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 25})); - controller.setAdmin(GUARDIAN); // own slot 257 -- the newest field, most at risk vm.stopPrank(); ExitFeeControllerV2Mock v2impl = new ExitFeeControllerV2Mock(); @@ -699,7 +632,6 @@ contract ExitFeeControllerTest is Test { // 2) Pre-upgrade state preserved. assertTrue(controller.exitFeeEnabled()); assertEq(controller.feeReceiver(), VAULT); - assertEq(controller.admin(), GUARDIAN); IExitFeeController.RatePolicy memory sp = controller.surfacePolicy(SURFACE); assertTrue(sp.active); assertEq(sp.rateBps, 25); @@ -714,7 +646,7 @@ contract ExitFeeControllerTest is Test { function test_non_owner_cannot_upgrade() public { ExitFeeControllerV2Mock v2impl = new ExitFeeControllerV2Mock(); vm.prank(OTHER); - vm.expectRevert("Ownable: caller is not the owner"); + vm.expectRevert(); // Ownable: caller is not the owner controller.upgradeTo(address(v2impl)); } @@ -822,13 +754,6 @@ contract ExitFeeControllerTest is Test { // The call itself not reverting IS the load-bearing assertion. IExitFeeController.ExitFeeQuote memory q = controller.quoteExitFee(SURFACE, IXUSD, ACTOR, grossAmount); - // Everything is configured on, so the overflow guard is the ONLY thing - // that can turn the quote off. Asserting the biconditional (rather than - // just "overflow implies INVALID_QUOTE") is what pins the guard's - // boundary: a guard that rejected one value too many would still - // satisfy the one-way version below. - assertEq(q.active, grossAmount <= type(uint256).max / 10_000, "active iff gross cannot overflow"); - if (q.active) { assertEq(q.feeAmount + q.netAmount, grossAmount, "conservation in active branch"); } else { @@ -878,6 +803,929 @@ contract ExitFeeControllerTest is Test { assertEq(uint256(q.rateBps), actorRateBps, "actor tier wins over sub-product and surface"); } + // ════════════════════════════════════════════════════════════════════ + // DELAY EXTENSION + // + // NOTE: in this suite `ADMIN` is the controller's OWNER (passed to + // `initialize`). The delay `Admin` GUARDIAN is a distinct address, set via + // `setAdmin` and stored below as `GUARDIAN`. + // ════════════════════════════════════════════════════════════════════ + + address constant GUARDIAN = address(0x6DA12D); // delay Admin guardian (≠ owner) + address constant WRAPPER = address(0x323A99); // a registered passthrough + address constant USER_EOA = address(0xE0A); // the human behind a wrapper burn + + // Re-declared so vm.expectEmit can match delay-extension events by topic. + event SecurityPerimeterEnabledSet(bool enabled); + event GlobalDelaySet(uint32 seconds_); + event AdminSet(address indexed admin); + event SurfaceBypassSet(bytes32 indexed surfaceId, bool active, bool bypass); + event SurfaceBypassRemoved(bytes32 indexed surfaceId); + event ActorBypassSet(bytes32 indexed surfaceId, address indexed actor, bool active, bool bypass); + event PassthroughActorSet(bytes32 indexed surfaceId, address indexed actor, bool isPassthrough); + + uint32 constant DELAY = 6 hours; + + // Convenience: enable the perimeter with a global delay, guardian set. + function _enableDelay(uint32 d) internal { + vm.startPrank(ADMIN); + controller.setAdmin(GUARDIAN); + controller.setGlobalDelaySeconds(d); + controller.setSecurityPerimeterEnabled(true); + vm.stopPrank(); + } + + function _bp(bool active, bool bypass) + internal + pure + returns (IExitFeeController.DelayBypassPolicy memory) + { + return IExitFeeController.DelayBypassPolicy({active: active, bypass: bypass}); + } + + // ─── Kill switch (both directions; independent of exitFeeEnabled) ──── + + function test_delay_off_by_default_short_circuits_to_raw() public view { + // Perimeter disabled by default: d == 0, raw identities echoed, registry + // NOT consulted (Finding 3 liveness escape). + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + assertEq(effOrig, ACTOR); // RAW, not normalized + assertEq(effOwner, OTHER); // RAW + } + + function test_kill_switch_enable_imposes_global_delay() public { + _enableDelay(DELAY); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "enabled perimeter imposes the global delay"); + } + + function test_kill_switch_disable_direction_by_guardian() public { + _enableDelay(DELAY); + // Guardian (Admin) can flip OFF (— both directions). + vm.prank(GUARDIAN); + controller.setSecurityPerimeterEnabled(false); + assertFalse(controller.securityPerimeterEnabled()); + (uint32 d, address effOrig,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0, "disabled -> direct"); + assertEq(effOrig, ACTOR, "disabled -> raw identities"); + } + + function test_kill_switch_enable_direction_by_guardian() public { + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + vm.prank(ADMIN); + controller.setGlobalDelaySeconds(DELAY); + // Guardian can also flip ON (both directions). + vm.prank(GUARDIAN); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + } + + function test_kill_switch_owner_can_flip_both_directions() public { + vm.startPrank(ADMIN); // owner + controller.setGlobalDelaySeconds(DELAY); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + controller.setSecurityPerimeterEnabled(false); + assertFalse(controller.securityPerimeterEnabled()); + vm.stopPrank(); + } + + function test_kill_switch_rejects_stranger() public { + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + vm.prank(OTHER); + vm.expectRevert(abi.encodeWithSelector(ExitFeeController.NotAdminOrOwner.selector, OTHER)); + controller.setSecurityPerimeterEnabled(true); + } + + function test_kill_switch_emits() public { + vm.prank(ADMIN); + vm.expectEmit(false, false, false, true); + emit SecurityPerimeterEnabledSet(true); + controller.setSecurityPerimeterEnabled(true); + } + + function test_perimeter_independent_of_exitFeeEnabled() public { + // The delay perimeter is INDEPENDENT of the fee kill switch and + // of any fee surfacePolicy. Fees OFF, perimeter ON ⇒ still delayed. + _enableDelay(DELAY); + assertFalse(controller.exitFeeEnabled(), "fees remain off"); + // No fee surfacePolicy configured at all. + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "delay fires with fees disabled and no fee policy"); + } + + function test_fee_surface_inactive_does_not_disable_delay() public { + // A fee-inactive surface can still be delay-active (sole gate is the + // perimeter switch, not surfacePolicy.active). + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: false, rateBps: 0})); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY, "fee surface inactive -> delay still fires"); + } + + // ─── globalDelaySeconds / floor (controller side) ─────────────────── + + function test_globalDelay_returned_faithfully() public { + // The controller returns EXACTLY globalDelaySeconds; the >= floor is a + // queue-side per-request check. Verify faithful passthrough across + // a couple of values incl. the max uint32. + _enableDelay(1); + (uint32 d1,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d1, 1); + + vm.prank(ADMIN); + controller.setGlobalDelaySeconds(type(uint32).max); + (uint32 d2,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d2, type(uint32).max, "~136y head-room passes through"); + } + + function test_globalDelay_zero_is_global_bypass() public { + // globalDelaySeconds == 0 with perimeter ON ⇒ d == 0 for every + // non-forced exit (equivalent to a global bypass; the queue skips it). + _enableDelay(0); + (uint32 d,,) = controller.quoteExitDelayFor(ACTOR, OTHER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + } + + function test_globalDelay_only_owner() public { + vm.prank(GUARDIAN); // guardian is NOT owner; global delay is owner-only + vm.expectRevert(); + controller.setGlobalDelaySeconds(DELAY); + } + + // ─── Bypass precedence (3-tier: actor > subProduct > surface) ─────── + + function test_bypass_default_no_tier_is_delayed() public { + _enableDelay(DELAY); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY); + } + + function test_surface_bypass_exempts() public { + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0, "surface bypass -> instant"); + // A different surface is untouched. + assertEq(controller.quoteExitDelay(SURFACE_OTHER, address(0), ACTOR), DELAY); + } + + function test_subProduct_bypass_overrides_surface() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); // surface: force delay + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, true)); // sub: bypass + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, ACTOR), 0, "sub-product bypass wins"); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY, "other sub falls to surface"); + } + + function test_actor_bypass_overrides_subProduct_and_surface() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, false)); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); // MM exemption + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, ACTOR), 0, "actor bypass wins"); + // A different actor still pays the (forced) delay. + assertEq(controller.quoteExitDelay(SURFACE, IWRBTC, OTHER), DELAY); + } + + function test_active_false_bypass_forces_delay_over_broader_bypass() public { + // The tricky override: a more-specific active {bypass:false} re-imposes + // delay even when a broader tier bypasses. + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); // broad bypass + controller.setActorBypass(SURFACE, ACTOR, _bp(true, false)); // re-impose on ACTOR + vm.stopPrank(); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), DELAY, "actor re-imposes delay"); + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, OTHER), 0, "others keep surface bypass"); + } + + function test_inactive_bypass_tier_falls_through() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); // surface bypass + controller.setActorBypass(SURFACE, ACTOR, _bp(false, true)); // INACTIVE — ignored + vm.stopPrank(); + // Inactive actor tier must NOT consume; falls through to surface bypass. + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0, "inactive actor tier falls through"); + } + + function test_subProduct_zero_skips_subProduct_tier() public { + // Zero passes subProduct=address(0); the resolver must NOT consult the + // sub-product map for address(0) (a sibling entry cannot leak in). + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setSubProductBypass(SURFACE_OTHER, IWRBTC, _bp(true, true)); + vm.stopPrank(); + // address(0) path falls straight through to surface (unconfigured) ⇒ global. + assertEq(controller.quoteExitDelay(SURFACE_OTHER, address(0), ACTOR), DELAY); + } + + // ─── Passthrough surface-scoping ────────────────────────────── + + function test_passthrough_resolves_to_receiver_on_scoped_surface() public { + _enableDelay(DELAY); + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + + // effectiveActor rewrites the wrapper to the receiver on THIS surface. + assertEq(controller.effectiveActor(SURFACE, WRAPPER, USER_EOA), USER_EOA); + // A non-passthrough is identity. + assertEq(controller.effectiveActor(SURFACE, ACTOR, USER_EOA), ACTOR); + + // quoteExitDelayFor returns the NORMALIZED originator/owner. + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(d, DELAY); + assertEq(effOrig, USER_EOA, "originator normalized wrapper->receiver"); + assertEq(effOwner, USER_EOA, "owner normalized wrapper->receiver"); + } + + function test_passthrough_is_surface_scoped_not_global() public { + _enableDelay(DELAY); + // Register WRAPPER as passthrough ONLY on the lending surface. + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + + // On Zero (no passthrough entry) the wrapper is NOT rewritten — margin/ + // Zero keep raw identities (never collapse originator/owner globally). + assertEq(controller.effectiveActor(SURFACE_OTHER, WRAPPER, USER_EOA), WRAPPER); + (, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE_OTHER, address(0)); + assertEq(effOrig, WRAPPER, "Zero keeps raw originator"); + assertEq(effOwner, WRAPPER, "Zero keeps raw owner"); + } + + function test_passthrough_actor_bypass_targets_effective_actor() public { + // Finding 2: the quote is on effOrig, so an actorBypass on the USER_EOA + // (not the wrapper) applies. Register wrapper passthrough + bypass on EOA. + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setActorBypass(SURFACE, USER_EOA, _bp(true, true)); // exempt the human + vm.stopPrank(); + + (uint32 d, address effOrig,) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(effOrig, USER_EOA); + assertEq(d, 0, "actorBypass on the effective (EOA) actor applies, not the wrapper"); + } + + function test_disabled_perimeter_skips_passthrough_resolution() public { + // Kill switch OFF: even with a passthrough registered, quoteExitDelayFor + // returns RAW identities (short-circuits BEFORE the registry). + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + // perimeter still disabled + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(WRAPPER, WRAPPER, USER_EOA, SURFACE, IXUSD); + assertEq(d, 0); + assertEq(effOrig, WRAPPER, "raw, registry not consulted"); + assertEq(effOwner, WRAPPER, "raw, registry not consulted"); + } + + function test_passthrough_deregister() public { + _enableDelay(DELAY); + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + assertTrue(controller.passthroughActor(SURFACE, WRAPPER)); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + vm.stopPrank(); + assertFalse(controller.passthroughActor(SURFACE, WRAPPER)); + assertEq(controller.effectiveActor(SURFACE, WRAPPER, USER_EOA), WRAPPER); + } + + function test_setPassthrough_zero_reverts_and_only_owner() public { + vm.prank(ADMIN); + vm.expectRevert(ExitFeeController.ActorZero.selector); + controller.setPassthroughActor(SURFACE, address(0), true); + + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + } + + // ─── Admin guardian setter (setAdmin) ─────────────────────────────── + + function test_setAdmin_sets_and_emits() public { + vm.prank(ADMIN); + vm.expectEmit(true, false, false, false); + emit AdminSet(GUARDIAN); + controller.setAdmin(GUARDIAN); + assertEq(controller.admin(), GUARDIAN); + } + + function test_setAdmin_rejects_zero() public { + vm.prank(ADMIN); + vm.expectRevert(ExitFeeController.AdminZero.selector); + controller.setAdmin(address(0)); + } + + function test_setAdmin_may_equal_owner() public { + // admin == owner is a + // supported shape (the governance Safe holds both roles at launch). + vm.prank(ADMIN); + controller.setAdmin(ADMIN); // ADMIN is the owner here + assertEq(controller.admin(), ADMIN); + } + + function test_setAdmin_only_owner() public { + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setAdmin(GUARDIAN); + } + + function test_default_admin_zero_owner_still_flips_kill_switch() public { + // Before setAdmin, admin == address(0). The Owner can still flip the + // kill switch (safe default: only Owner until a guardian is appointed); + // a stranger (and address(0) callers can't exist) cannot. + assertEq(controller.admin(), address(0)); + vm.prank(ADMIN); + controller.setSecurityPerimeterEnabled(true); + assertTrue(controller.securityPerimeterEnabled()); + } + + // ─── Bypass setter validation / enumeration / removal ─────────────── + + function test_bypass_setters_only_owner() public { + vm.startPrank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + vm.expectRevert("Ownable: caller is not the owner"); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); + vm.stopPrank(); + } + + function test_bypass_zero_address_reverts() public { + vm.startPrank(ADMIN); + vm.expectRevert(ExitFeeController.SubProductZero.selector); + controller.setSubProductBypass(SURFACE, address(0), _bp(true, true)); + vm.expectRevert(ExitFeeController.ActorZero.selector); + controller.setActorBypass(SURFACE, address(0), _bp(true, true)); + vm.stopPrank(); + } + + function test_bypass_batch_and_length_mismatch() public { + vm.startPrank(ADMIN); + address[] memory actors = new address[](2); + actors[0] = ACTOR; + actors[1] = CAFE; + IExitFeeController.DelayBypassPolicy[] memory ps = new IExitFeeController.DelayBypassPolicy[](2); + ps[0] = _bp(true, true); + ps[1] = _bp(true, false); + controller.setActorBypasses(SURFACE, actors, ps); + + // Length mismatch reverts. + IExitFeeController.DelayBypassPolicy[] memory bad = new IExitFeeController.DelayBypassPolicy[](1); + bad[0] = _bp(true, true); + vm.expectRevert(ExitFeeController.LengthMismatch.selector); + controller.setActorBypasses(SURFACE, actors, bad); + vm.stopPrank(); + + address[] memory keys = controller.actorBypassKeys(SURFACE); + assertEq(keys.length, 2); + assertEq(keys[0], ACTOR); + assertEq(keys[1], CAFE); + } + + function test_bypass_enumeration_and_hard_removal() public { + vm.startPrank(ADMIN); + controller.setSubProductBypass(SURFACE, IWRBTC, _bp(true, true)); + controller.setSubProductBypass(SURFACE, IXUSD, _bp(true, false)); + assertEq(controller.subProductBypassKeys(SURFACE).length, 2); + + controller.removeSubProductBypass(SURFACE, IWRBTC); + vm.stopPrank(); + + assertEq(controller.subProductBypassKeys(SURFACE).length, 1); + IExitFeeController.DelayBypassPolicy memory p = controller.subProductBypass(SURFACE, IWRBTC); + assertFalse(p.active); + assertFalse(p.bypass); + } + + function test_bypass_remove_idempotent_when_absent() public { + vm.recordLogs(); + vm.prank(ADMIN); + controller.removeActorBypass(SURFACE, ACTOR); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0, "no event when nothing to remove"); + } + + function test_bypass_keys_retained_on_soft_retire() public { + vm.startPrank(ADMIN); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, true)); + controller.setActorBypass(SURFACE, ACTOR, _bp(false, false)); // soft retire + vm.stopPrank(); + address[] memory keys = controller.actorBypassKeys(SURFACE); + assertEq(keys.length, 1, "key retained on soft retire"); + assertEq(keys[0], ACTOR); + } + + // ─── quoteExitDelay inner view: disabled-perimeter parity ─────────── + + function test_inner_quote_returns_zero_when_disabled() public { + // Even with a forcing bypass configured, the inner view returns 0 while + // the perimeter is off (parity with quoteExitDelayFor). + vm.startPrank(ADMIN); + controller.setGlobalDelaySeconds(DELAY); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, false)); // would force delay + vm.stopPrank(); + // perimeter disabled + assertEq(controller.quoteExitDelay(SURFACE, IXUSD, ACTOR), 0); + } + + // ─── Fuzz / property: precedence + short-circuit + faithful delay ─── + + /// @dev Property: when the perimeter is ON and no bypass tier is configured, + /// quoteExitDelay returns EXACTLY globalDelaySeconds for any actor and + /// any (non-zero-or-zero) subProduct — the "default delayed" rule. + function testFuzz_default_delay_equals_global(uint32 d, address actor, address sub) public { + _enableDelay(d); + assertEq(controller.quoteExitDelay(SURFACE, sub, actor), d); + } + + /// @dev Property: an active actor bypass ALWAYS decides on effOrig, + /// regardless of the sub-product and surface tiers. + function testFuzz_actor_bypass_always_wins( + uint32 d, + bool surfActive, + bool surfBypass, + bool subActive, + bool subBypass, + bool actorBypassVal + ) public { + vm.assume(d > 0); + _enableDelay(d); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(surfActive, surfBypass)); + controller.setSubProductBypass(SURFACE, IXUSD, _bp(subActive, subBypass)); + controller.setActorBypass(SURFACE, ACTOR, _bp(true, actorBypassVal)); + vm.stopPrank(); + + uint32 got = controller.quoteExitDelay(SURFACE, IXUSD, ACTOR); + assertEq(got, actorBypassVal ? 0 : d, "active actor tier decides"); + } + + /// @dev Property: disabled perimeter ALWAYS returns (0, raw, owner) — the + /// registry is never consulted and identities are never normalized, + /// for any inputs (controller-side). + function testFuzz_disabled_always_raw_and_zero( + address raw, + address owner_, + address receiver, + address sub, + bool registerPassthrough + ) public { + // Optionally register a passthrough; it must NOT be consulted while off. + if (registerPassthrough && raw != address(0)) { + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, raw, true); + } + // perimeter disabled (default) + (uint32 d, address effOrig, address effOwner) = + controller.quoteExitDelayFor(raw, owner_, receiver, SURFACE, sub); + assertEq(d, 0); + assertEq(effOrig, raw, "raw originator echoed"); + assertEq(effOwner, owner_, "raw owner echoed"); + } + + /// @dev Property: quoteExitDelayFor and the inner quoteExitDelay agree on the + /// resolved delay when the perimeter is ON and no passthrough rewrites + /// the actor (so effOrig == rawOriginator). + function testFuzz_outer_inner_agree(uint32 d, address actor, address sub) public { + vm.assume(actor != WRAPPER); // no passthrough registered anyway + _enableDelay(d); + (uint32 outer,,) = controller.quoteExitDelayFor(actor, actor, actor, SURFACE, sub); + uint32 inner = controller.quoteExitDelay(SURFACE, sub, actor); + assertEq(outer, inner, "outer and inner resolve the same delay"); + } + + // ─── Admin == Owner is a supported shape ── + // + // The `_transferOwnership` chokepoint and setAdmin's + // owner-equality check were removed by team decision: at launch the + // governance Safe holds BOTH roles. These pin the new behavior: role + // merges via the 2-step handoff succeed, and normal rotations are + // unaffected. + + function test_transferOwnership_to_admin_then_accept_merges_roles() public { + // Appoint a guardian, then hand ownership to that same guardian via + // the 2-step flow. Both steps succeed; owner == admin afterwards. + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + + vm.prank(ADMIN); + controller.transferOwnership(GUARDIAN); // stages pendingOwner = GUARDIAN + assertEq(controller.pendingOwner(), GUARDIAN, "pending staged"); + + vm.prank(GUARDIAN); + controller.acceptOwnership(); + + assertEq(controller.owner(), GUARDIAN, "roles merged: guardian is now owner"); + assertEq(controller.admin(), GUARDIAN, "admin unchanged"); + } + + function test_transferOwnership_to_nonadmin_still_works() public { + // A normal rotation to a fresh (non-admin) owner is unaffected. + vm.prank(ADMIN); + controller.setAdmin(GUARDIAN); + + vm.prank(ADMIN); + controller.transferOwnership(OTHER); // OTHER != admin + vm.prank(OTHER); + controller.acceptOwnership(); + assertEq(controller.owner(), OTHER, "rotation to a non-admin owner succeeds"); + } + + function test_initialize_handoff_admin_unset_at_init() public { + // At initialize, admin == address(0); it is only appointed AFTER init + // via setAdmin. A fresh deploy handing off to a Safe starts adminless. + ExitFeeController impl = new ExitFeeController(); + bytes memory init = abi.encodeWithSelector(ExitFeeController.initialize.selector, CAFE); + ERC1967Proxy proxy = new ERC1967Proxy(address(impl), init); + ExitFeeController c = ExitFeeController(address(proxy)); + assertEq(c.owner(), CAFE, "immediate handoff at init succeeds"); + assertEq(c.admin(), address(0), "admin unset until setAdmin"); + } + + // ─── surface-bypass + passthrough registries are ENUMERABLE ── + + function test_surfaceBypassKeys_enumerates_no_argument() public { + // The getter takes NO argument — surface bypasses are keyed by surfaceId + // alone. Configure two surfaces and confirm both are listed. + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE_OTHER, _bp(true, false)); + vm.stopPrank(); + + bytes32[] memory keys = controller.surfaceBypassKeys(); + assertEq(keys.length, 2, "both configured surfaces enumerated"); + // Order is set-insertion order. + assertEq(keys[0], SURFACE); + assertEq(keys[1], SURFACE_OTHER); + } + + function test_surfaceBypassKeys_idempotent_on_overwrite() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE, _bp(true, false)); // overwrite same id + vm.stopPrank(); + assertEq(controller.surfaceBypassKeys().length, 1, "no duplicate key on overwrite"); + } + + function test_surfaceBypassKeys_retained_on_soft_retire() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + controller.setSurfaceBypass(SURFACE, _bp(false, false)); // soft retire + vm.stopPrank(); + bytes32[] memory keys = controller.surfaceBypassKeys(); + assertEq(keys.length, 1, "soft-retired surface still enumerable"); + assertEq(keys[0], SURFACE); + } + + function test_removeSurfaceBypass_hard_removes_and_emits() public { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + assertEq(controller.surfaceBypassKeys().length, 1); + + vm.expectEmit(true, false, false, false); + emit SurfaceBypassRemoved(SURFACE); + controller.removeSurfaceBypass(SURFACE); + vm.stopPrank(); + + assertEq(controller.surfaceBypassKeys().length, 0, "key dropped"); + IExitFeeController.DelayBypassPolicy memory p = controller.surfaceBypass(SURFACE); + assertFalse(p.active, "policy cleared"); + assertFalse(p.bypass); + } + + function test_removeSurfaceBypass_idempotent_when_absent() public { + vm.recordLogs(); + vm.prank(ADMIN); + controller.removeSurfaceBypass(SURFACE); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 0, "no event when nothing to remove"); + } + + function test_removeSurfaceBypass_only_owner() public { + vm.prank(ADMIN); + controller.setSurfaceBypass(SURFACE, _bp(true, true)); + vm.prank(OTHER); + vm.expectRevert("Ownable: caller is not the owner"); + controller.removeSurfaceBypass(SURFACE); + } + + function test_bypass_enumeration_under_arbitrary_surfaceId() public { + // A bypass set under an arbitrary surfaceId (never registered as a named + // fee surface) must still be fully enumerated across all three tiers. + bytes32 arbitrary = keccak256("ARBITRARY:SURFACE:XYZ"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(arbitrary, _bp(true, true)); + controller.setSubProductBypass(arbitrary, IXUSD, _bp(true, false)); + controller.setActorBypass(arbitrary, ACTOR, _bp(true, true)); + vm.stopPrank(); + + bytes32[] memory sKeys = controller.surfaceBypassKeys(); + assertEq(sKeys.length, 1); + assertEq(sKeys[0], arbitrary, "arbitrary surface enumerated"); + assertEq(controller.subProductBypassKeys(arbitrary).length, 1); + assertEq(controller.subProductBypassKeys(arbitrary)[0], IXUSD); + assertEq(controller.actorBypassKeys(arbitrary).length, 1); + assertEq(controller.actorBypassKeys(arbitrary)[0], ACTOR); + } + + function test_passthroughKeys_enumerates_and_drops_on_deregister() public { + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setPassthroughActor(SURFACE, CAFE, true); + vm.stopPrank(); + + address[] memory keys = controller.passthroughKeys(SURFACE); + assertEq(keys.length, 2, "both passthroughs enumerated"); + assertEq(keys[0], WRAPPER); + assertEq(keys[1], CAFE); + + // Deregister WRAPPER: exact-to-live (dropped from the index, unlike the + // soft-retained bypass key-sets). + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + address[] memory keys2 = controller.passthroughKeys(SURFACE); + assertEq(keys2.length, 1, "deregistered passthrough dropped from index"); + assertEq(keys2[0], CAFE); + } + + function test_passthroughKeys_under_arbitrary_surfaceId() public { + // A passthrough registered under an arbitrary surfaceId is enumerated. + bytes32 arbitrary = keccak256("ARBITRARY:PASSTHROUGH:QQQ"); + vm.prank(ADMIN); + controller.setPassthroughActor(arbitrary, WRAPPER, true); + address[] memory keys = controller.passthroughKeys(arbitrary); + assertEq(keys.length, 1); + assertEq(keys[0], WRAPPER); + // Surface-scoped: NOT visible under a different surface. + assertEq(controller.passthroughKeys(SURFACE).length, 0); + } + + function test_passthroughKeys_idempotent_reregister() public { + vm.startPrank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, true); + controller.setPassthroughActor(SURFACE, WRAPPER, true); // re-register + vm.stopPrank(); + assertEq(controller.passthroughKeys(SURFACE).length, 1, "no duplicate on re-register"); + } + + function test_passthroughKeys_deregister_absent_is_noop() public { + // Deregistering an address that was never registered leaves the index + // empty and does not revert. + vm.prank(ADMIN); + controller.setPassthroughActor(SURFACE, WRAPPER, false); + assertEq(controller.passthroughKeys(SURFACE).length, 0); + assertFalse(controller.passthroughActor(SURFACE, WRAPPER)); + } + + /// @dev Property: every actively-registered passthrough is + /// enumerated, and enumeration membership tracks the boolean flag exactly + /// (register -> present, deregister -> absent) under a random walk. + function testFuzz_passthroughKeys_membership_tracks_flag(address a, bool register, bool thenDeregister) + public + { + vm.assume(a != address(0)); + bytes32 s = SURFACE; + vm.startPrank(ADMIN); + if (register) { + controller.setPassthroughActor(s, a, true); + if (thenDeregister) controller.setPassthroughActor(s, a, false); + } + vm.stopPrank(); + + bool expectPresent = register && !thenDeregister; + assertEq(controller.passthroughActor(s, a), expectPresent, "flag matches expectation"); + + address[] memory keys = controller.passthroughKeys(s); + bool found = false; + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == a) { + found = true; + break; + } + } + assertEq(found, expectPresent, "index membership tracks the boolean flag exactly"); + } + + /// @dev Property: the surface-bypass key-set contains a + /// surfaceId iff a surface bypass was set-and-not-hard-removed for it. + function testFuzz_surfaceBypassKeys_membership(bytes32 s, bool active, bool bypass, bool thenRemove) + public + { + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(active, bypass)); + if (thenRemove) controller.removeSurfaceBypass(s); + vm.stopPrank(); + + bytes32[] memory keys = controller.surfaceBypassKeys(); + bool found = false; + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == s) { + found = true; + break; + } + } + // set-then-remove -> absent; set-only (even soft-retired) -> present. + assertEq(found, !thenRemove, "membership = set && !hardRemove"); + } + + // ════════════════════════════════════════════════════════════════════ + // — ANY-TIER-TOUCHED master id-sets + // + // The exact gap the previous cycle left: the master surface-id set was + // populated ONLY by setSurfaceBypass, so a sub-product- or actor-ONLY + // bypass (the most common exemption shape, actor tier) or a + // passthrough-only entry under an arbitrary surfaceId was undiscoverable + // by any single enumeration getter. These tests pin that NO zero-delay / + // identity-collapse config is invisible to the inspector's driver. + // ════════════════════════════════════════════════════════════════════ + + /// @dev REGRESSION (the exact gap): configure ONLY an actor-tier bypass with + /// NO prior setSurfaceBypass. The surfaceId MUST appear in + /// bypassSurfaceIds() even though surfaceBypassKeys() (surface-tier only) + /// does not contain it. + function test_bypassSurfaceIds_records_actor_only_bypass() public { + bytes32 arbitrary = keccak256("ARBITRARY:ACTOR:ONLY"); + address someMM = address(0x11A11); // a market-maker actor + vm.prank(ADMIN); + controller.setActorBypass( + arbitrary, someMM, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + + // The OLD surface-tier-only driver misses it: + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier set stays empty"); + + // The any-tier-touched master set discovers it: + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "actor-only bypass surfaces the id"); + assertEq(ids[0], arbitrary, "the arbitrary surfaceId is discoverable"); + + // And the per-surface actor tier is reachable from that id. + address[] memory actorKeys = controller.actorBypassKeys(arbitrary); + assertEq(actorKeys.length, 1); + assertEq(actorKeys[0], someMM); + } + + /// @dev REGRESSION: a sub-product-ONLY bypass with no prior setSurfaceBypass + /// is likewise discoverable via bypassSurfaceIds(). + function test_bypassSurfaceIds_records_subproduct_only_bypass() public { + bytes32 arbitrary = keccak256("ARBITRARY:SUBPRODUCT:ONLY"); + vm.prank(ADMIN); + controller.setSubProductBypass(arbitrary, IXUSD, _bp(true, false)); + + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier set stays empty"); + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1); + assertEq(ids[0], arbitrary); + assertEq(controller.subProductBypassKeys(arbitrary)[0], IXUSD); + } + + /// @dev The surface tier also records into the master set (so all three + /// writers feed it), and the master set dedups a surfaceId touched at + /// multiple tiers. + function test_bypassSurfaceIds_dedups_across_tiers() public { + bytes32 s = keccak256("ARBITRARY:ALL:TIERS"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(true, true)); + controller.setSubProductBypass(s, IXUSD, _bp(true, false)); + controller.setActorBypass(s, ACTOR, _bp(true, true)); + vm.stopPrank(); + + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "single id even though 3 tiers touched it"); + assertEq(ids[0], s); + } + + /// @dev REGRESSION: removeSurfaceBypass while sub/actor entries remain live + /// does NOT drop the id from discovery — the id stays in the master set + /// so the inspector keeps probing the still-live sub/actor tiers. + function test_bypassSurfaceIds_retained_after_removeSurfaceBypass() public { + bytes32 s = keccak256("ARBITRARY:REMOVE:SURFACE"); + vm.startPrank(ADMIN); + controller.setSurfaceBypass(s, _bp(true, true)); + controller.setActorBypass(s, ACTOR, _bp(true, true)); // still-live actor tier + controller.removeSurfaceBypass(s); // drop ONLY the surface tier + vm.stopPrank(); + + // Surface-tier key-set drops it (hard remove of the surface tier)... + assertEq(controller.surfaceBypassKeys().length, 0, "surface-tier key dropped"); + // ...but the any-tier master set retains it (actor tier still live). + bytes32[] memory ids = controller.bypassSurfaceIds(); + assertEq(ids.length, 1, "master set retains id while sub/actor live"); + assertEq(ids[0], s); + assertEq(controller.actorBypassKeys(s)[0], ACTOR, "actor tier still live"); + } + + /// @dev REGRESSION: a passthrough-ONLY entry under an arbitrary surfaceId + /// (no bypass at any tier, not a named fee surface) is discoverable via + /// passthroughSurfaceIds(). + function test_passthroughSurfaceIds_records_passthrough_only_entry() public { + bytes32 arbitrary = keccak256("ARBITRARY:PASSTHROUGH:ONLY"); + vm.prank(ADMIN); + controller.setPassthroughActor(arbitrary, WRAPPER, true); + + // No bypass at any tier for this surfaceId: + assertEq(controller.bypassSurfaceIds().length, 0, "no bypass touched this id"); + assertEq(controller.surfaceBypassKeys().length, 0); + + // But the passthrough master set discovers it: + bytes32[] memory ids = controller.passthroughSurfaceIds(); + assertEq(ids.length, 1, "passthrough-only surface discoverable"); + assertEq(ids[0], arbitrary); + assertEq(controller.passthroughKeys(arbitrary)[0], WRAPPER); + } + + /// @dev passthroughSurfaceIds() is surface-level retention: the id stays + /// recorded even after every passthrough under it is deregistered (the + /// per-surface passthroughKeys going empty is the live signal). This + /// guarantees the inspector never loses the probe point. + function test_passthroughSurfaceIds_retained_after_deregister() public { + bytes32 s = keccak256("ARBITRARY:PASSTHROUGH:DEREG"); + vm.startPrank(ADMIN); + controller.setPassthroughActor(s, WRAPPER, true); + controller.setPassthroughActor(s, WRAPPER, false); // deregister the only one + vm.stopPrank(); + + // Per-surface live set is now empty... + assertEq(controller.passthroughKeys(s).length, 0, "no live passthrough under s"); + assertFalse(controller.passthroughActor(s, WRAPPER)); + // ...but the surface-level master set retains the probe point. + bytes32[] memory ids = controller.passthroughSurfaceIds(); + assertEq(ids.length, 1, "surface-level id retained for probing"); + assertEq(ids[0], s); + } + + /// @dev Property: bypassSurfaceIds() contains a surfaceId iff at least one + /// bypass tier (surface / sub-product / actor) was EVER written for it — + /// independent of which tier, and independent of soft-retire / surface + /// hard-remove (retention-only master set). + function testFuzz_bypassSurfaceIds_membership_any_tier(bytes32 s, uint8 tier, bool active, bool bypass) + public + { + vm.startPrank(ADMIN); + tier = uint8(bound(tier, 0, 2)); + if (tier == 0) { + controller.setSurfaceBypass(s, _bp(active, bypass)); + } else if (tier == 1) { + controller.setSubProductBypass(s, IXUSD, _bp(active, bypass)); + } else { + controller.setActorBypass(s, ACTOR, _bp(active, bypass)); + } + vm.stopPrank(); + + bytes32[] memory ids = controller.bypassSurfaceIds(); + bool found = false; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == s) { + found = true; + break; + } + } + assertTrue(found, "any tier write records the surfaceId in the master set"); + } + + /// @dev Property: passthroughSurfaceIds() contains a surfaceId iff a + /// passthrough was EVER registered under it (register-only recording, + /// surface-level retention). + function testFuzz_passthroughSurfaceIds_membership( + bytes32 s, + address a, + bool register, + bool thenDeregister + ) public { + vm.assume(a != address(0)); + vm.startPrank(ADMIN); + if (register) { + controller.setPassthroughActor(s, a, true); + if (thenDeregister) controller.setPassthroughActor(s, a, false); + } + vm.stopPrank(); + + bytes32[] memory ids = controller.passthroughSurfaceIds(); + bool found = false; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == s) { + found = true; + break; + } + } + // Ever-registered (even if later deregistered) -> present; never -> absent. + assertEq(found, register, "master set records on register, retains on deregister"); + } + // ─── Invariant testing design (NOT IMPLEMENTED -- sketch for follow-up) ── // // Stateful invariant testing in Foundry needs a Handler contract that diff --git a/test/unit/InspectControllerDiscovery.t.sol b/test/unit/InspectControllerDiscovery.t.sol new file mode 100644 index 0000000..487c5f0 --- /dev/null +++ b/test/unit/InspectControllerDiscovery.t.sol @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {IExitFeeController} from "../../src/interfaces/IExitFeeController.sol"; +import {InspectController} from "../../script/InspectController.s.sol"; + +/// @dev Test harness exposing InspectController's internal discovery driver so +/// the dump-path can be exercised without deployment artifacts / RPC. The +/// inspector prints from `_probeSurfaceIds` (the any-tier-touched union), +/// so proving that union CONTAINS an actor-only / passthrough-only id is +/// exactly the "dump path discovers it" regression. +contract InspectHarness is InspectController { + function probeSurfaceIds(ExitFeeController c) external view returns (bytes32[] memory) { + return _probeSurfaceIds(c); + } +} + +/// @title — InspectController discovery completeness +contract InspectControllerDiscoveryTest is Test { + address constant OWNER = address(0xC0FFEE); + address constant ACTOR = address(0xAC); + address constant SUB = address(0x1750D); + address constant WRAP = address(0x323A99); + + // A named fee surface, and two ARBITRARY surfaces never registered as fee surfaces. + bytes32 constant NAMED = keccak256("COLFEE:SURFACE_LENDING_LENDER_WITHDRAW"); + bytes32 constant ARB_ACTOR = keccak256("ARBITRARY:ACTOR:ONLY"); + bytes32 constant ARB_PASS = keccak256("ARBITRARY:PASSTHROUGH:ONLY"); + + ExitFeeController controller; + InspectHarness harness; + + function setUp() public { + ExitFeeController impl = new ExitFeeController(); + bytes memory init = abi.encodeWithSelector(ExitFeeController.initialize.selector, OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(impl), init))); + harness = new InspectHarness(); + } + + function _contains(bytes32[] memory ids, bytes32 x) internal pure returns (bool) { + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == x) return true; + } + return false; + } + + /// @dev The exact gap: an actor-ONLY bypass under an arbitrary surfaceId with + /// NO prior setSurfaceBypass must appear in the inspector's probe set. + function test_probe_discovers_actor_only_bypass_under_arbitrary_surface() public { + vm.prank(OWNER); + controller.setActorBypass( + ARB_ACTOR, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_ACTOR), "actor-only arbitrary surface discovered by dump path"); + // Named fee surfaces are always folded in (human rows). + assertTrue(_contains(ids, NAMED), "named fee surface always probed"); + } + + /// @dev A sub-product-ONLY bypass under an arbitrary surfaceId is discovered. + function test_probe_discovers_subproduct_only_bypass() public { + vm.prank(OWNER); + controller.setSubProductBypass( + ARB_ACTOR, SUB, IExitFeeController.DelayBypassPolicy({active: true, bypass: false}) + ); + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_ACTOR), "sub-product-only arbitrary surface discovered"); + } + + /// @dev A passthrough-ONLY entry under an arbitrary surfaceId (no bypass at any + /// tier, not named) is discovered via passthroughSurfaceIds(). + function test_probe_discovers_passthrough_only_entry() public { + vm.prank(OWNER); + controller.setPassthroughActor(ARB_PASS, WRAP, true); + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue(_contains(ids, ARB_PASS), "passthrough-only arbitrary surface discovered"); + } + + /// @dev removeSurfaceBypass while an actor entry stays live keeps the id in + /// the probe set (retention-only master set). + function test_probe_retains_id_after_removeSurfaceBypass_with_live_actor() public { + vm.startPrank(OWNER); + controller.setSurfaceBypass( + ARB_ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.setActorBypass( + ARB_ACTOR, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.removeSurfaceBypass(ARB_ACTOR); + vm.stopPrank(); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + assertTrue( + _contains(ids, ARB_ACTOR), + "id retained in probe set while actor tier live after surface hard-remove" + ); + } + + /// @dev The probe set is deduplicated: a surfaceId that is named AND carries + /// bypass + passthrough entries appears exactly once. + function test_probe_dedups_named_and_touched_surface() public { + vm.startPrank(OWNER); + controller.setActorBypass( + NAMED, ACTOR, IExitFeeController.DelayBypassPolicy({active: true, bypass: true}) + ); + controller.setPassthroughActor(NAMED, WRAP, true); + vm.stopPrank(); + + bytes32[] memory ids = harness.probeSurfaceIds(controller); + uint256 count = 0; + for (uint256 i = 0; i < ids.length; i++) { + if (ids[i] == NAMED) count++; + } + assertEq(count, 1, "named+touched surface appears exactly once"); + } +} diff --git a/test/unit/VerifyActivation.t.sol b/test/unit/VerifyActivation.t.sol new file mode 100644 index 0000000..cbe6e8b --- /dev/null +++ b/test/unit/VerifyActivation.t.sol @@ -0,0 +1,714 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {ExitFeeController} from "../../src/ExitFeeController.sol"; +import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {IExitDelayQueueHost} from "../../src/interfaces/IExitDelayQueueHost.sol"; +import {VerifyActivation} from "../../script/06_VerifyActivation.s.sol"; + +/// @dev Test harness exposing the internal host-resolution helpers so the C1 +/// defer/warning/require logic is driveable on in-memory addresses WITHOUT +/// `vm.setEnv` (which mutates process-global env forge does not isolate +/// between parallel test functions — a genuine race). +contract VerifyActivationHarness is VerifyActivation { + function resolveHosts(bool deferHosts, address sovrynHost, address zeroHost) + external + view + returns (address[] memory) + { + return _resolveHosts(deferHosts, sovrynHost, zeroHost); + } + + /// Expose the banner emitter so BOTH legs — the unqualified "safe to run step 8" + /// banner (anyHostDeferred==false) and the qualified/warned downgrade + /// (anyHostDeferred==true) — are exercised deterministically WITHOUT the env→run + /// path (G5R2-02 coverage). View-only, no state, no revert. + function reportPass(bool anyHostDeferred) external view { + _reportPass(anyHostDeferred); + } +} + +/// @dev Minimal WRBTC stand-in for the queue's `wrbtc_` init param. +contract MockWRBTC is ERC20 { + constructor() ERC20("Wrapped RBTC", "WRBTC") {} + receive() external payable {} +} + +/// @dev Minimal product-host stand-in implementing the `setExitDelayQueue` pointer +/// so the C2 wiring assertions can be driven end-to-end. +contract MockProductHost is IExitDelayQueueHost { + address public exitDelayQueue; + + function setExitDelayQueue(address queue) external override { + exitDelayQueue = queue; + } +} + +/// @title activation step-7 COMPREHENSIVE go-live gate — 06_VerifyActivation (C1/C2) +/// @notice Proves the read-only verify gate reverts with a DISTINCT message on each +/// misconfig and PASSES on a fully-correct config: +/// (a) guardian — "not yet configured" vs mismatch +/// (b) floor — "not yet configured" vs sub-floor +/// (C1) ownership — owner still deployer / owner != governance +/// (C2) wiring — host not wired / host wired-but-not-allowed-source +/// plus the run(chainId) artifact-reading path. +contract VerifyActivationTest is Test { + // (SP2-G5R2-02 / GATE4-03) A DEDICATED test-only chain id — NOT 31337 — so the + // run()-path artifact writes land in deployments/31338/ and never clobber a real + // local anvil deploy's deployments/31337/*.json. fs_permissions grants the whole + // ./deployments tree, so no foundry.toml change is needed. + uint256 constant CHAIN_ID = 31338; + + address constant CTRL_OWNER = address(0xC0FFEE); // controller Owner after init + address constant GUARDIAN = address(0x6DA12D); // shared single guardian + address constant OTHER_ADMIN = address(0xBAD); // a DIFFERENT guardian -> mis-wired + address constant QUEUE_OWNER = address(0x0E7E7); // queue Owner after init; != guardian + address constant GOV_OWNER = address(0x60F); // intended governance Owner (C1) + address constant DEPLOYER = address(0xDEEDDE); // broadcast EOA (C1: must own neither) + address constant STRAY_OWNER = address(0x57A11); // not deployer, not governance (C1) + address constant SOURCE = address(0x50117CE); + + uint32 constant FLOOR = 3600; + + VerifyActivationHarness script; + MockWRBTC wrbtc; + + // Distinct concrete host addresses for the C1 host-resolution tests. + address constant SOVRYN_HOST = address(0x50147117); + address constant ZERO_HOST = address(0x2E70117); + ExitFeeController controller; + ExitDelayQueue queue; + + MockProductHost host; // registered allowed-source + wired by default + + function setUp() public { + script = new VerifyActivationHarness(); + wrbtc = new MockWRBTC(); + + // ── Controller owned by CTRL_OWNER (so we later transfer it to GOV_OWNER). ── + ExitFeeController cImpl = new ExitFeeController(); + bytes memory cInit = abi.encodeWithSelector(ExitFeeController.initialize.selector, CTRL_OWNER); + controller = ExitFeeController(address(new ERC1967Proxy(address(cImpl), cInit))); + + // ── Queue owned by QUEUE_OWNER; guardian GUARDIAN; the host pre-registered + // as an allowed-source so the default (correct) config is C2-clean. ── + host = new MockProductHost(); + address[] memory sources = new address[](2); + sources[0] = SOURCE; + sources[1] = address(host); + ExitDelayQueue qImpl = new ExitDelayQueue(); + bytes memory qInit = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, QUEUE_OWNER, GUARDIAN, address(wrbtc), FLOOR, sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(qImpl), qInit)))); + + // Wire the host's queue pointer at THIS queue (C2 default: wired). + host.setExitDelayQueue(address(queue)); + } + + // ─── Helpers ────────────────────────────────────────────────────────── + + /// Run activation steps 4–5 (Owner actions) on the controller. + function _configController(address admin_, uint32 globalDelay_) internal { + vm.startPrank(CTRL_OWNER); + controller.setGlobalDelaySeconds(globalDelay_); // step 4 + controller.setAdmin(admin_); // step 5 + vm.stopPrank(); + } + + /// Hand BOTH contracts to the intended governance Owner (Ownable2Step). + function _handToGovernance() internal { + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + } + + /// The default single-host intended list. + function _hosts() internal view returns (address[] memory hs) { + hs = new address[](1); + hs[0] = address(host); + } + + /// Drive the full comprehensive gate with the canonical args (non-deferred: the + /// default single-host list is non-empty so the C1 empty-list guard is satisfied). + function _verify() internal view { + script.verify(controller, queue, GOV_OWNER, DEPLOYER, _hosts(), false); + } + + /// A fully-correct deploy: config done, ownership handed off, host wired+allowed. + function _makeFullyCorrect() internal { + _configController(GUARDIAN, FLOOR); + _handToGovernance(); + } + + // ─── (a) guardian: distinct "not yet configured" vs mismatch ───── + + function test_verify_reverts_distinctly_when_admin_unconfigured() public { + _handToGovernance(); // ownership fine; admin still 0 + assertEq(controller.admin(), address(0), "precondition: admin unset"); + vm.expectRevert(bytes("guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first")); + _verify(); + } + + function test_verify_reverts_on_admin_mismatch() public { + _configController(OTHER_ADMIN, FLOOR); // configured, admin != queue.admin + _handToGovernance(); + vm.expectRevert( + bytes("single guardian violated: controller.admin() != queue.admin() -- single guardian violated") + ); + _verify(); + } + + // ─── (b) floor: distinct "not yet configured" vs sub-floor ───────── + + function test_verify_reverts_distinctly_when_global_delay_unconfigured() public { + vm.prank(CTRL_OWNER); + controller.setAdmin(GUARDIAN); // step 5 only; delay still 0 + _handToGovernance(); + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } + + function test_verify_reverts_distinctly_on_subfloor_global_delay() public { + _configController(GUARDIAN, FLOOR - 1); + _handToGovernance(); + vm.expectRevert( + bytes( + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ) + ); + _verify(); + } + + // ─── (C1) ownership: owner still deployer / owner != governance ──────── + + /// Queue still owned by the deployer EOA (ownership never handed off): the + /// still-deployer check fires with the C1 "== deployer" message. + function test_verify_reverts_when_queue_owner_still_deployer() public { + _configController(GUARDIAN, FLOOR); + // Hand ONLY the controller to governance; move the queue to the DEPLOYER + // (simulating the silent-blank-owner footgun: deployer left in control). + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + + vm.prank(QUEUE_OWNER); + queue.transferOwnership(DEPLOYER); + vm.prank(DEPLOYER); + queue.acceptOwnership(); + + vm.expectRevert( + bytes("SP2-CTRL-02 (C1): queue.owner() == deployer EOA -- ownership not handed to governance") + ); + _verify(); + } + + /// Controller still owned by the deployer EOA. + function test_verify_reverts_when_controller_owner_still_deployer() public { + _configController(GUARDIAN, FLOOR); + // Queue -> governance; controller -> deployer. + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + + vm.prank(CTRL_OWNER); + controller.transferOwnership(DEPLOYER); + vm.prank(DEPLOYER); + controller.acceptOwnership(); + + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1): controller.owner() == deployer EOA -- ownership not handed to governance" + ) + ); + _verify(); + } + + /// Queue owned by a NON-deployer address that is also not the intended + /// governance Owner: the "!= governance owner" message fires. + function test_verify_reverts_when_queue_owner_not_governance() public { + _configController(GUARDIAN, FLOOR); + address strayOwner = STRAY_OWNER; + // controller -> governance (correct) + vm.prank(CTRL_OWNER); + controller.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + controller.acceptOwnership(); + // queue -> stray (not deployer, not governance) + vm.prank(QUEUE_OWNER); + queue.transferOwnership(strayOwner); + vm.prank(strayOwner); + queue.acceptOwnership(); + + vm.expectRevert(bytes("SP2-CTRL-02 (C1): queue.owner() != governance owner")); + _verify(); + } + + function test_verify_reverts_when_controller_owner_not_governance() public { + _configController(GUARDIAN, FLOOR); + address strayOwner = STRAY_OWNER; + // queue -> governance (correct) + vm.prank(QUEUE_OWNER); + queue.transferOwnership(GOV_OWNER); + vm.prank(GOV_OWNER); + queue.acceptOwnership(); + // controller -> stray + vm.prank(CTRL_OWNER); + controller.transferOwnership(strayOwner); + vm.prank(strayOwner); + controller.acceptOwnership(); + + vm.expectRevert(bytes("SP2-CTRL-02 (C1): controller.owner() != governance owner")); + _verify(); + } + + /// C1 arg hygiene: a zero governance-owner arg is a config error, not a pass. + function test_verify_reverts_when_governance_owner_arg_zero() public { + _makeFullyCorrect(); + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1 unconfigured): governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + ) + ); + script.verify(controller, queue, address(0), DEPLOYER, _hosts(), false); + } + + function test_verify_reverts_when_deployer_arg_zero() public { + _makeFullyCorrect(); + vm.expectRevert(bytes("SP2-CTRL-02 (C1 unconfigured): deployer arg == 0 -- set EXIT_DELAY_DEPLOYER")); + script.verify(controller, queue, GOV_OWNER, address(0), _hosts(), false); + } + + function test_verify_reverts_when_governance_owner_equals_deployer() public { + _makeFullyCorrect(); + vm.expectRevert( + bytes("SP2-CTRL-02 (C1 unconfigured): governance owner == deployer -- they must differ") + ); + script.verify(controller, queue, GOV_OWNER, GOV_OWNER, _hosts(), false); + } + + // ─── (C2) wiring: host not wired / host wired-but-not-allowed-source ─── + + /// Host's queue pointer points elsewhere (or unset): fail-open zero-delay. + function test_verify_reverts_when_host_not_wired() public { + _makeFullyCorrect(); + // Re-point the host at a bogus queue address (unwired w.r.t. THIS queue). + host.setExitDelayQueue(address(0xDEAD)); + vm.expectRevert( + bytes( + string.concat( + "SP2-CTRL-02 (C2): host ", + vm.toString(address(host)), + " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" + ) + ) + ); + _verify(); + } + + /// Host wired at THIS queue but NOT registered as an allowed-source: bricked + /// fail-closed (its record*() would revert UnregisteredSource). + function test_verify_reverts_when_host_wired_but_not_allowed_source() public { + // Deploy a SECOND host that is wired but never registered as a source. + MockProductHost unregHost = new MockProductHost(); + unregHost.setExitDelayQueue(address(queue)); + _makeFullyCorrect(); + + address[] memory hs = new address[](1); + hs[0] = address(unregHost); + + vm.expectRevert( + bytes( + string.concat( + "SP2-CTRL-02 (C2): host ", + vm.toString(address(unregHost)), + " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" + ) + ) + ); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + /// A zero host slipped into the intended list is rejected (defensive C2). + function test_verify_reverts_on_zero_host_in_list() public { + _makeFullyCorrect(); + address[] memory hs = new address[](1); + hs[0] = address(0); + vm.expectRevert(bytes("SP2-CTRL-02 (C2): intended host == 0")); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + // ─── (C1) host-input safety: required inputs + explicit VERIFY_DEFER_HOSTS ─ + // (replaces the deleted vacuous-pass test). Drives the + // _resolveHosts helper: the require/defer/warning logic that gates whether + // an intended-host list may ever be empty. The `run` path's REQUIRED-read + // (non-defer branch of _readHostEnv) revert-on-unset rests on forge's + // `vm.envAddress` cheatcode contract — it reverts when the var is unset or + // mistyped — so it is NOT re-asserted through an env-driven test here (see the + // NOTE below for why env-driven revert assertions are deliberately omitted: + // the process-global env race between parallel test functions/suites). + + /// deferHosts=false + both hosts non-zero: BOTH are C2-checked (list length 2). + function test_resolveHosts_both_present_no_defer() public view { + address[] memory hs = script.resolveHosts(false, SOVRYN_HOST, ZERO_HOST); + assertEq(hs.length, 2, "both hosts must be in the C2-checked list"); + assertEq(hs[0], SOVRYN_HOST, "sovryn host first"); + assertEq(hs[1], ZERO_HOST, "zero host second"); + } + + /// deferHosts=false + a zero sovryn host: HARD REVERT (no silent empty list / + /// vacuous PASS — a surface would ship unwired at zero-delay, fail-open). + function test_resolveHosts_reverts_on_zero_sovryn_without_defer() public { + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1): intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, address(0), ZERO_HOST); + } + + /// deferHosts=false + a zero Zero host: HARD REVERT (same rule, other surface). + function test_resolveHosts_reverts_on_zero_zerohost_without_defer() public { + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1): intended host Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, SOVRYN_HOST, address(0)); + } + + /// deferHosts=false + BOTH hosts zero (fresh-shell / all-typo'd): HARD REVERT + /// (the sovryn host is checked first). This is the exact vacuous-pass hole the + /// C1 fix closes — an empty list can NEVER be produced without an explicit opt-in. + function test_resolveHosts_reverts_on_both_zero_without_defer() public { + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1): intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + ) + ); + script.resolveHosts(false, address(0), address(0)); + } + + /// deferHosts=true + one host deferred (zero): the present host is checked, the + /// zero host is dropped WITH a loud warning (printed by _includeHost). No revert. + function test_resolveHosts_defers_zero_host_with_warning() public view { + address[] memory hs = script.resolveHosts(true, SOVRYN_HOST, address(0)); + assertEq(hs.length, 1, "only the wired host is C2-checked"); + assertEq(hs[0], SOVRYN_HOST, "the present host is retained"); + } + + /// deferHosts=true + BOTH hosts deferred (zero): allowed ONLY because deferral + /// is EXPLICIT — an empty list is produced but with loud per-host warnings. + function test_resolveHosts_defers_both_hosts_with_defer_optin() public view { + address[] memory hs = script.resolveHosts(true, address(0), address(0)); + assertEq(hs.length, 0, "both hosts explicitly deferred"); + } + + /// deferHosts=true but BOTH hosts present: defer opt-in does NOT drop a wired + /// host — both are still C2-checked (opt-in only excuses a ZERO host). + function test_resolveHosts_defer_true_but_both_present_still_checks_both() public view { + address[] memory hs = script.resolveHosts(true, SOVRYN_HOST, ZERO_HOST); + assertEq(hs.length, 2, "present hosts are always checked, defer or not"); + } + + /// (SP2-G5R2-01 / GATE4-02) A NON-ZERO duplicate host pair (both spec-named vars + /// pointing at the SAME address) HARD REVERTS — one surface would be C2-checked + /// twice while the OTHER ships silently unchecked/unwired. Applies regardless of + /// deferHosts (the check runs BEFORE the include/defer logic). + function test_resolveHosts_reverts_on_duplicate_nonzero_hosts() public { + vm.expectRevert( + bytes("SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + ); + script.resolveHosts(false, SOVRYN_HOST, SOVRYN_HOST); + } + + /// The duplicate check fires even under deferHosts=true when the + /// pair is a genuine NON-ZERO duplicate — deferral excuses a ZERO host, never a + /// copy-paste of the same real address into both slots. + function test_resolveHosts_reverts_on_duplicate_nonzero_hosts_even_when_deferred() public { + vm.expectRevert( + bytes("SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + ); + script.resolveHosts(true, ZERO_HOST, ZERO_HOST); + } + + /// (SP2-G5R2-01 regression) The both-ZERO pair under deferHosts=true is NOT a + /// duplicate — it is the explicit both-host deferral and must still yield an empty + /// list (proves the `|| == address(0)` clause preserves the defer-both path). + function test_resolveHosts_both_zero_not_treated_as_duplicate_when_deferred() public view { + address[] memory hs = script.resolveHosts(true, address(0), address(0)); + assertEq(hs.length, 0, "both-zero defer is not a duplicate; empty list allowed"); + } + + // ─── (C1) verify() refuses a vacuous PASS on an EMPTY host list ───────── + // The dry-run entrypoint must NOT silently certify go-live when the C2 wiring + // loop asserts nothing (empty list). An empty list is allowed ONLY when the + // caller EXPLICITLY asserts deferral (deferHosts==true) — the legitimate + // defer-both run() path. + + /// verify(..., empty list, deferHosts=false): HARD REVERT — an empty intended- + /// host list without an explicit defer opt-in is the exact fail-open vacuous PASS + /// the C1 decision mandates removing. + function test_verify_reverts_on_empty_host_list_without_defer() public { + _makeFullyCorrect(); + address[] memory empty = new address[](0); + vm.expectRevert( + bytes( + "SP2-CTRL-02 (C1): empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + ) + ); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, empty, false); + } + + /// verify(..., empty list, deferHosts=true): PASSES — the legitimate qualified / + /// deferred path (both hosts explicitly deferred to a later SIP). The other four + /// sub-checks (guardian/floor/ownership) still run and hold. + function test_verify_passes_on_empty_host_list_with_explicit_defer() public { + _makeFullyCorrect(); + address[] memory empty = new address[](0); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, empty, true); // does not revert + } + + /// (C1 regression) A NON-empty list with deferHosts=false still passes the empty- + /// list guard and proceeds to the sub-checks (guards against an over-broad guard). + function test_verify_nonempty_list_no_defer_passes_guard() public { + _makeFullyCorrect(); + _verify(); // non-empty _hosts(), deferHosts=false — passes + } + + // ─── (G5R2-02) _reportPass banner — BOTH legs, deterministically ──────── + // Driven through the harness (no env→run), so the qualified/downgraded banner + // branch (previously never executed) is exercised race-free. + + /// anyHostDeferred=false ⇒ the UNQUALIFIED "safe to run step 8" banner leg runs. + function test_reportPass_unqualified_when_no_host_deferred() public view { + script.reportPass(false); // view, no revert — exercises the unqualified leg + } + + /// anyHostDeferred=true ⇒ the QUALIFIED / warned downgrade banner leg runs (the + /// branch a real deferral takes; keyed off the resolved fact, not the raw flag). + function test_reportPass_qualified_when_a_host_deferred() public view { + script.reportPass(true); // view, no revert — exercises the downgrade leg + } + + // ─── (coverage 114-116) _readHostEnv deferHosts==true (envOr fallback) leg ── + // NOT driven here. `_readHostEnv` reads the SHARED host env keys + // (SOVRYN_PROTOCOL_HOST / ZERO_BORROWER_OPERATIONS_HOST); ANY test that + // touches them via `vm.setEnv` races the single clean-pass env→run() test + // (forge does not isolate process-global env between the concurrently- + // scheduled functions of one contract — empirically: an empty-string set here + // leaked into that test's `vm.envAddress` and broke its address parse). The + // defer leg is a one-line `vm.envOr(key, address(0))` whose "return the + // default on unset" behavior is a forge-cheatcode guarantee (same rationale as + // the REQUIRED-read revert leg, deliberately not env-tested). The DEFER + // SEMANTICS it feeds — a zero host dropped-with-warning vs a hard revert — are + // pinned deterministically on in-memory addresses by the test_resolveHosts_* + // suite. + + // ─── (d) PASSES on a fully-correct config ────────────────────────────── + + function test_verify_passes_when_fully_correct_at_floor() public { + _makeFullyCorrect(); + _verify(); // does not revert + } + + function test_verify_passes_when_delay_above_floor() public { + _configController(GUARDIAN, FLOOR * 2); + _handToGovernance(); + _verify(); + } + + // ─── (C2) floor MAY be 0; the `> 0` guard is on the ACTIVE delay ── + + /// @dev Redeploy the queue with a ZERO minimumDelaySeconds floor, re-wiring the + /// host + re-registering sources, so the C2 "floor MAY be 0" cases run + /// against a real zero-floor queue. + function _redeployQueueWithZeroFloor() internal { + host = new MockProductHost(); + address[] memory sources = new address[](2); + sources[0] = SOURCE; + sources[1] = address(host); + ExitDelayQueue qImpl = new ExitDelayQueue(); + bytes memory qInit = abi.encodeWithSelector( + ExitDelayQueue.initialize.selector, QUEUE_OWNER, GUARDIAN, address(wrbtc), uint32(0), sources + ); + queue = ExitDelayQueue(payable(address(new ERC1967Proxy(address(qImpl), qInit)))); + host.setExitDelayQueue(address(queue)); + } + + /// A zero `minimumDelaySeconds` FLOOR is NOT a go-live blocker: with a positive + /// globalDelaySeconds the gate PASSES (the per-request DelayBelowFloor backstop + /// is simply inactive — Owner-remediable, not a blocker). + function test_verify_passes_when_floor_is_zero_and_delay_positive() public { + _redeployQueueWithZeroFloor(); + assertEq(queue.minimumDelaySeconds(), 0, "precondition: zero floor"); + _configController(GUARDIAN, 1); // smallest positive active delay + _handToGovernance(); + _verify(); // does not revert — floor==0 is allowed, delay>0 satisfies C2 + } + + /// Even with a zero FLOOR, a zero ACTIVE globalDelaySeconds is REJECTED — a zero + /// global delay leaves the perimeter inert (C2 `> 0` applies to the active delay). + function test_verify_reverts_when_global_delay_zero_even_with_zero_floor() public { + _redeployQueueWithZeroFloor(); + vm.prank(CTRL_OWNER); + controller.setAdmin(GUARDIAN); // guardian ok; globalDelaySeconds stays 0 + _handToGovernance(); + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } + + /// Multiple intended hosts, all wired + allowed: passes. + function test_verify_passes_with_multiple_hosts() public { + MockProductHost host2 = new MockProductHost(); + host2.setExitDelayQueue(address(queue)); + vm.prank(QUEUE_OWNER); + queue.addAllowedSource(address(host2)); + + _makeFullyCorrect(); + + address[] memory hs = new address[](2); + hs[0] = address(host); + hs[1] = address(host2); + script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); + } + + // ─── Ordering: guardian < floor < ownership < wiring (first unmet wins) ─ + + /// Nothing configured: (guardian) is the FIRST unmet invariant, so its + /// message fires ahead of the floor/ownership/wiring messages. + function test_verify_guardian_reported_first() public { + // admin==0, delay==0, ownership not handed, host fine. + vm.expectRevert(bytes("guardian unconfigured: controller.admin()==0 -- run step 5 (setAdmin) first")); + _verify(); + } + + // ─── run(chainId) artifact + env path ────────────────────────────────── + + /// @dev Write the deployment artifacts + the always-required (non-host) env vars + /// for the `run` path. Returns nothing; caller sets the host env vars per + /// the scenario under test. + function _writeRunArtifactsAndOwners() internal { + string memory dir = string.concat("deployments/", vm.toString(CHAIN_ID), "/"); + // (SP2-G5R2-02 / GATE4-03) Create the artifact dir first — on a FRESH clone + // deployments/31338/ does not exist and vm.writeFile would fail. `true` = + // recursive / no-error-if-exists. + vm.createDir(dir, true); + vm.writeFile( + string.concat(dir, "ExitFeeController.json"), + string.concat('{"proxyAddress":"', vm.toString(address(controller)), '"}') + ); + vm.writeFile( + string.concat(dir, "ExitDelayQueue.json"), + string.concat('{"proxyAddress":"', vm.toString(address(queue)), '"}') + ); + vm.setEnv("EXIT_DELAY_GOVERNANCE_OWNER", vm.toString(GOV_OWNER)); + vm.setEnv("EXIT_DELAY_DEPLOYER", vm.toString(DEPLOYER)); + } + + /// Deploy + register + wire a SECOND host so BOTH spec-named env hosts can point + /// at real wired allowed-sources for the clean-pass run path. + function _makeSecondWiredHost() internal returns (MockProductHost h2) { + h2 = new MockProductHost(); + h2.setExitDelayQueue(address(queue)); + vm.prank(QUEUE_OWNER); + queue.addAllowedSource(address(h2)); + } + + /// Clean go-live: BOTH hosts set, wired + allowed, no defer. run() passes and + /// emits the UNQUALIFIED banner. + function test_run_passes_cleanly_when_both_hosts_wired() public { + MockProductHost host2 = _makeSecondWiredHost(); + _makeFullyCorrect(); + _writeRunArtifactsAndOwners(); + + vm.setEnv("SOVRYN_PROTOCOL_HOST", vm.toString(address(host))); + vm.setEnv("ZERO_BORROWER_OPERATIONS_HOST", vm.toString(address(host2))); + vm.setEnv("VERIFY_DEFER_HOSTS", "false"); + + script.run(CHAIN_ID); // does not revert; unqualified PASS banner + } + + // NOTE — why only ONE env→run() test, and how the defer/banner branches are + // covered instead: + // + // `vm.setEnv` mutates PROCESS-global env that forge does NOT isolate between the + // (concurrently-scheduled) test functions of one contract. A SECOND env→run() + // test that set the host keys to DIFFERENT values than this one would race: its + // `vm.setEnv("ZERO_BORROWER_OPERATIONS_HOST", …)` can land between this test's set + // and `run()`'s read (empirically reproduced — a leaked non-defer host makes the + // C2 wiring check revert on a host this test never wired). So exactly ONE env→run + // test lives here (the clean unqualified-PASS path above), pinning the artifact + + // env + banner wiring end to end. + // + // The remaining C1/G5R2-02 branches are pinned DETERMINISTICALLY on in-memory + // addresses via the harness (NO env, race-free): + // • the REQUIRED-read revert-on-unset (non-defer leg of _readHostEnv) rests on + // forge's `vm.envAddress` cheatcode contract (reverts on unset/typo); + // • the defer leg of _readHostEnv (returns zero when VERIFY_DEFER_HOSTS=true) + // is covered via the test_resolveHosts_defers_* cases below; + // • the require / defer-warning / duplicate-host branches of _resolveHosts → + // the test_resolveHosts_* suite; + // • the empty-list vacuous-PASS refusal + defer-both pass → the + // test_verify_*_empty_host_list_* tests; + // • BOTH banner legs (unqualified + the G5R2-02 qualified/warned DOWNGRADE) → + // test_reportPass_unqualified_* / test_reportPass_qualified_* (which drive the + // exact `_reportPass(anyHostDeferred)` the run() path calls, keyed off the + // resolved fact `hosts.length < 2` that the run() body computes). + + // ─── Property: gate passes IFF ALL invariants hold; each specific unmet + // invariant maps to its specific revert reason, checked in gate order. ─── + + function testFuzz_verify_gate_and_reason(uint32 globalDelay, bool sameAdmin) public { + address ctrlAdmin = sameAdmin ? GUARDIAN : OTHER_ADMIN; + _configController(ctrlAdmin, globalDelay); + _handToGovernance(); // ownership + wiring always correct here + + // Guardian is checked first, then floor. Ownership/wiring are correct, so + // the outcome is fully determined by (sameAdmin, globalDelay). + if (!sameAdmin) { + vm.expectRevert( + bytes( + "single guardian violated: controller.admin() != queue.admin() -- single guardian violated" + ) + ); + _verify(); + } else if (globalDelay == 0) { + vm.expectRevert( + bytes( + "delay unconfigured: controller.globalDelaySeconds()==0 -- run step 4 (setGlobalDelaySeconds) first" + ) + ); + _verify(); + } else if (globalDelay < FLOOR) { + vm.expectRevert( + bytes( + "sub-floor delay: controller.globalDelaySeconds() < queue.minimumDelaySeconds() -- sub-floor delay self-bricks exits" + ) + ); + _verify(); + } else { + _verify(); // passes + } + } +} diff --git a/tools/check-abi-equivalence.sh b/tools/check-abi-equivalence.sh index 6e8a865..7ad5a5e 100755 --- a/tools/check-abi-equivalence.sh +++ b/tools/check-abi-equivalence.sh @@ -2,18 +2,38 @@ # # Cross-pragma ABI guard for the ExitFeeController interface. # -# Two files declare the SAME ABI surface: +# Two files declare the ExitFeeController ABI surface: # src/interfaces/IExitFeeController.sol (range pragma >=0.5.17 <0.9.0) # src/interfaces/v0_4/IExitFeeController.sol (0.4.26 outlier, AMM) # +# ── Fee surface vs delay extension ─────────────────────────── +# The unified file carries TWO surfaces: +# * the FEE surface (RatePolicy / ExitFeeQuote / quoteExitFee / policy +# setters + views + events) — consumed under 0.5.17 (Sovryn-smart), +# 0.6.11 (zero), 0.8.20 (this repo) AND 0.4.26 (AMM); and +# * the DELAY extension (securityPerimeterEnabled / globalDelaySeconds / +# admin / bypass tiers / passthrough registry / quoteExitDelay*), +# added by the Security-Perimeter delay feature. +# The AMM (0.4.26) is DEFERRED and "swaps are never delayed" (spec), so it +# never consumes the delay extension. The `v0_4/` outlier is therefore +# intentionally FEE-ONLY (26 members). The guard's job is to protect the +# fee surface the AMM actually calls — NOT to force ~20 dead delay members +# into a 0.4.26 interface. Check 1 below is accordingly a SUBSET relation: +# every v0_4 member MUST appear byte-for-byte in the unified ABI (fee surface +# matches exactly across pragmas); the unified file MAY carry additional +# (delay) members the outlier omits. A fee-side struct reorder or signature +# drift still fails (the reordered member no longer matches its v0_4 twin). +# # This script enforces equivalence on three axes that a casual # `forge inspect ... methodIdentifiers` diff would NOT catch: # -# 1. Full ABI shape: function inputs/outputs (including struct tuples), -# event `indexed` flags, `stateMutability`, `anonymous`, and error -# signatures. A struct field reorder inside `ExitFeeQuote` would keep -# function selectors identical (selectors only hash inputs) but break -# callers decoding the returned tuple. +# 1. Fee-surface ABI shape (SUBSET): every v0_4 member — function +# inputs/outputs (including struct tuples), event `indexed` flags, +# `stateMutability`, `anonymous`, and error signatures — must appear +# identically in the unified ABI. A struct field reorder inside +# `ExitFeeQuote` would keep function selectors identical (selectors +# only hash inputs) but break callers decoding the returned tuple, and +# it breaks the subset match here. # # 2. SkipReason enum ordinal order. The ABI encodes the enum as uint8, # so `forge inspect` cannot see a reorder. Synthesized skip-reason @@ -74,16 +94,34 @@ normalize_abi() { )' } -echo "── 1) Full ABI equivalence (inputs/outputs/indexed/stateMutability) ──" -if ! diff -u \ - <(forge inspect "$UNIFIED_FQ" abi --json | normalize_abi) \ - <(forge inspect "$V0_4_FQ" abi --json | normalize_abi); then - echo " error: ABIs differ — see diff above." >&2 - echo " Catches: function/event/error signatures, struct tuple" >&2 - echo " component order, indexed flags, stateMutability, anonymous." >&2 +echo "── 1) Fee-surface ABI subset (v0_4 ⊆ unified; exact match per member) ──" +# The v0_4 outlier is the fee-only reference surface. Every one of its +# normalized ABI members MUST appear byte-for-byte in the unified ABI. The +# unified ABI may carry extra (delay) members the outlier omits — those are +# NOT required in v0_4 (AMM never delays). jq computes `v0_4 − unified` +# (set difference on normalized members): a non-empty result is a fee-surface +# mismatch (drift or a struct reorder) and fails the gate. +UNIFIED_ABI=$(forge inspect "$UNIFIED_FQ" abi --json | normalize_abi) +V0_4_ABI=$(forge inspect "$V0_4_FQ" abi --json | normalize_abi) + +MISSING=$(jq -n \ + --argjson u "$UNIFIED_ABI" \ + --argjson v "$V0_4_ABI" \ + '$v | map(select( . as $x | ($u | any(. == $x)) | not ))') + +if [ "$(echo "$MISSING" | jq 'length')" != "0" ]; then + echo " error: v0_4 fee-surface members absent from (or divergent in) the unified ABI:" >&2 + echo "$MISSING" | jq -r '.[] | " \(.type) \(.name // "(anonymous)")"' >&2 + echo " Catches: fee function/event/error signature drift, struct tuple" >&2 + echo " component order, indexed flags, stateMutability, anonymous —" >&2 + echo " on the surface the 0.4.26 AMM actually consumes." >&2 + echo " (Delay-extension members legitimately live ONLY in the unified" >&2 + echo " file; they are NOT required in the v0_4 outlier )" >&2 exit 1 fi -echo " ok" +V0_4_COUNT=$(echo "$V0_4_ABI" | jq 'length') +UNIFIED_COUNT=$(echo "$UNIFIED_ABI" | jq 'length') +echo " ok ($V0_4_COUNT/$V0_4_COUNT v0_4 fee members matched; unified carries $UNIFIED_COUNT total)" # ─── 2) SkipReason enum ordinal equivalence ─────────────────────────────── # The ABI shows uint8 only; `forge inspect` cannot see the enum variant order. From 81cce0a4a64161b20622129e6c3f7478ca649e79 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Tue, 18 Aug 2026 00:35:12 +0300 Subject: [PATCH 02/12] Keep `admin` at the slot the fee release already shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delay extension declared `securityPerimeterEnabled` and `globalDelaySeconds` ahead of `admin`. All three are small enough to share one slot, so Solidity packed them together and moved `admin` from offset 0 to offset 5 of slot 257 — a slot the deployed controller already uses. Upgrading the live proxy to that layout would have reinterpreted the stored admin address: the perimeter would have read as enabled with no governance action, the global delay as roughly 112 years, and `admin` as an address nobody holds. Nothing would have reverted. `admin` is now declared first and alone, exactly where the fee release put it, and the two delay scalars move into a slot reclaimed from `__gap`. Slot 271 is then closed with an explicit reservation: left half-used, its 27 free bytes would capture the next field any future upgrade appends, landing it outside the reserved gap and tripping the same class of check. The layout mirrors in the upgrade-safety fixtures move with it. tools/diff-storage-layouts.py now reports the candidate upgrade-safe against the recorded mainnet layout, where it previously failed; the fixture harness accepts the positive case and still rejects both negatives. Behaviour is unchanged — no logic, no interface, no ABI difference — and the suite is green at 389 tests. --- src/ExitFeeController.sol | 125 ++++++++++++++++++--------------- test/fixtures/BadV3.sol | 10 +-- test/fixtures/GoodPackedV2.sol | 32 +++++---- 3 files changed, 93 insertions(+), 74 deletions(-) diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index e37582c..7d657ec 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -58,17 +58,11 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 255 _subProductKeys mapping head (enumeration index) // 256 _actorKeys mapping head (enumeration index) // - // ── Delay extension (added BELOW the fee slots) ── - // 257 securityPerimeterEnabled (1 byte) + globalDelaySeconds (4) + - // admin (20 bytes) = 25 bytes -- PACKED into ONE slot. (admin - // is a SINGLE guardian address, NOT an OZ AccessControl role, - // the controller stays single-Owner; only the queue carries - // a two-principal machinery. The perimeter kill switch - // and -- since the core merge -- the - // fee levers `setExitFeeEnabled` / `setFeeReceiver` need an - // `Admin`-capable path, so one packed address is the minimal - // addition and costs ZERO extra slots by packing with the - // bool + uint32. The SAME field serves both gates.) + // 257 admin -- the FEE release already shipped this slot, so it + // is fixed: `admin` at offset 0, alone. The delay extension + // adds NOTHING beside it, even though 12 bytes are free. + // + // ── Delay extension (added inside the slots __gap reserved) ── // 258 _surfaceBypass mapping head // 259 _subProductBypass mapping head // 260 _actorBypass mapping head @@ -82,27 +76,26 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 268 _bypassSurfaceIds._indexes (mapping head) ┘ // 269 _passthroughSurfaceIds._values (Bytes32Set array head) ┐ 2 slots // 270 _passthroughSurfaceIds._indexes (mapping head) ┘ - // 271 .. 300 __gap[30] -- preserves the OZ-style 50-slot namespace - // (50 - 20 own slots used). + // 271 securityPerimeterEnabled (1 byte) + globalDelaySeconds + // (4 bytes) + __slot271Reserved (27 bytes) -- one reclaimed + // gap slot, fully consumed so later upgrades start clean. + // 272 .. 300 __gap[29] -- preserves the OZ-style 50-slot namespace + // (50 - 21 own slots used). // - // Own-slot count re-derived at implementation via `forge inspect - // ExitFeeController storage-layout`: 6 fee slots + 14 delay slots - // (1 packed scalar+admin slot + 3 bypass mapping heads + 1 surface-bypass - // Bytes32Set [2 slots] + 2 sub/actor bypass enumeration heads + 1 passthrough - // nested-mapping head + 1 passthrough enumeration head + 1 any-tier-touched - // bypass Bytes32Set [2 slots] + 1 passthrough-surface Bytes32Set [2 slots]) - // = 20, so __gap = 50 - 20 = 30 (added the two - // any-tier-touched master sets on top of 's per-tier sets). - // Since ColFee is not yet deployed, this is a first-deploy layout choice, - // not a UUPS migration. + // Own slots: 6 fee (251 packed + 252..256) + 1 admin (257) + 14 delay + // (258..271) = 21, so __gap = 50 - 21 = 29 and the namespace still ends + // at 300. // - // Merge note: the core branch declared a standalone - // `admin` at slot 257 (__gap[43]); this branch's packed slot-257 - // `admin` absorbs it -- one field, both gates, layout above unchanged. + // WHY the two delay scalars are NOT packed beside `admin`: they would fit + // (20 + 1 + 4 = 25 bytes), and the free bytes read as zero, which is the + // desired disabled-at-upgrade state. But slot 257 is NOT part of the gap + // the shipped fee layout reserved, and the upgrade-safety check admits new + // state only inside reclaimed gap slots. Spending one gap slot out of the + // thirty available is cheaper than relaxing that check. // - // Upgrades that add storage to THIS contract MUST consume from __gap - // and reduce its length by exactly the number of slots added. They - // MUST NOT reorder, insert, or change the type of any preceding slot. + // Upgrades that add storage to THIS contract MUST consume from __gap and + // reduce its length by exactly the number of slots added. They MUST NOT + // reorder, insert, or change the type of any preceding slot. using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; @@ -166,32 +159,6 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; - // ─── Delay extension storage ──────────────────────────────────────── - // - // `securityPerimeterEnabled` (bool, 1 byte), `globalDelaySeconds` - // (uint32, 4 bytes) and `admin` (address, 20 bytes) are declared - // consecutively and therefore share one slot. Any change to the order or - // width of these three moves `admin`, so re-derive the layout with - // `forge inspect` and re-check it against the deployed record before - // shipping an upgrade. - - /// @notice Global kill switch for the DELAY perimeter. Independent of - /// `exitFeeEnabled`: turning fees off does NOT disable the - /// perimeter, and a fee-inactive surface can still be delay-active. - /// When false, `quoteExitDelayFor` short-circuits to - /// `(0, raw, owner)` without consulting the bypass tiers, the - /// passthrough registry, or the queue. - bool public securityPerimeterEnabled; - - /// @notice One delay for EVERY surface (uint32 gives ~136 years of head - /// room). There is no per-surface delay *duration* — only the - /// per-tier bypass toggles below exempt a surface, sub-product or - /// actor. The `>= queue.minimumDelaySeconds` relationship is a - /// liveness invariant enforced PER-REQUEST in the queue, not a - /// cross-contract setter guard here: the controller never reads or - /// calls the queue. - uint32 public globalDelaySeconds; - /// @notice Fast operational guardian. A SINGLE stored address checked by /// `onlyAdminOrOwner` -- NOT an OZ AccessControl role (the /// controller stays single-Owner for configuration). It authorizes @@ -201,8 +168,21 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// MAY equal the owner -- nothing requires the two authorities to /// be distinct. Unset (`address(0)`) until the owner appoints one; /// while unset, `onlyAdminOrOwner` admits only the owner. + /// + /// DECLARED FIRST, ALONE IN ITS SLOT, and BEFORE any delay field: + /// `admin` already exists in the deployed proxy at slot 257 + /// offset 0. Packing anything ahead of it would shift it within + /// the slot and make the stored address unreadable. Nothing may be + /// inserted above this line. address public admin; + // ─── Delay extension storage ──────────────────────────────────────── + // + // Everything below is NEW state, taken from `__gap`. `admin` above keeps + // the slot it already occupies in the deployed proxy, and nothing is + // packed into the free bytes beside it: new state starts at the first + // slot the deployed layout reserved as gap. + /// @dev Surface-tier delay bypass. Key: `surfaceId`. Value: /// `DelayBypassPolicy {active, bypass}`. Mirrors `_surfacePolicy`'s /// shape but is INDEPENDENT of it — the delay resolver never @@ -281,8 +261,41 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// loses the probe point). `passthroughSurfaceIds()` exposes it. EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; + // The two delay scalars are declared HERE, last, rather than beside + // `admin`. Declared next to `admin` they would pack into the free bytes of + // its slot -- safe in itself, but that slot is not part of the gap the + // deployed layout reserved, and the upgrade-safety check (rightly) only + // admits new state inside reclaimed gap slots. Declared here they share + // one clean gap slot instead, and the check passes unmodified. + + /// @notice Global kill switch for the DELAY perimeter. Independent of + /// `exitFeeEnabled`: turning fees off does NOT disable the + /// perimeter, and a fee-inactive surface can still be delay-active. + /// When false, `quoteExitDelayFor` short-circuits to + /// `(0, raw, owner)` without consulting the bypass tiers, the + /// passthrough registry, or the queue. + bool public securityPerimeterEnabled; + + /// @notice One delay for EVERY surface (uint32 gives ~136 years of head + /// room). There is no per-surface delay *duration* -- only the + /// per-tier bypass toggles above exempt a surface, sub-product or + /// actor. The `>= queue.minimumDelaySeconds` relationship is a + /// liveness invariant enforced PER-REQUEST in the queue, not a + /// cross-contract setter guard here: the controller never reads or + /// calls the queue. + uint32 public globalDelaySeconds; + + /// @dev Closes slot 271. Without it the slot keeps 27 free bytes, and the + /// next field a future upgrade appends would pack into them — landing + /// in a slot that is NOT part of the gap this release reserves, which + /// the upgrade-safety check refuses. Reserving the remainder here + /// costs nothing (the slot is already spent) and lets every later + /// upgrade start cleanly at the next whole gap slot. + // aderyn-ignore-next-line(unused-state-variable) + uint216 private __slot271Reserved; + // aderyn-ignore-next-line(unused-state-variable) - uint256[30] private __gap; + uint256[29] private __gap; // ─── Custom errors ────────────────────────────────────────────────── diff --git a/test/fixtures/BadV3.sol b/test/fixtures/BadV3.sol index 0c780d8..9939119 100644 --- a/test/fixtures/BadV3.sol +++ b/test/fixtures/BadV3.sol @@ -25,7 +25,7 @@ import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// /// HOW TO REGENERATE (do this whenever ExitFeeController's storage /// changes): mirror `forge inspect ExitFeeController storageLayout` -/// EXACTLY (slots 251..270 today, __gap unchanged at [30]) — the +/// EXACTLY (slots 251..271 today, __gap unchanged at [29]) — the /// ONLY intentional deviation is the LOCAL RatePolicy below whose /// two members are swapped. Everything else must match byte-for-byte /// so the tool rejects for the struct reorder and NOT for a missing @@ -61,8 +61,6 @@ contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; // slot 257 (packed: bool@0, uint32@1, address@5) — DO NOT reorder. - bool public securityPerimeterEnabled; - uint32 public globalDelaySeconds; address public admin; // slots 258..270 — DelayBypassPolicy imported so it stays identical. @@ -78,7 +76,11 @@ contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) // __gap unchanged — this fixture adds NO storage; it only reorders a struct. - uint256[30] private __gap; + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; + uint216 private __slot271Reserved; + + uint256[29] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } diff --git a/test/fixtures/GoodPackedV2.sol b/test/fixtures/GoodPackedV2.sol index 14687b2..9c500fe 100644 --- a/test/fixtures/GoodPackedV2.sol +++ b/test/fixtures/GoodPackedV2.sol @@ -26,14 +26,14 @@ import {IExitFeeController} from "../../src/ExitFeeController.sol"; /// /// HOW TO REGENERATE (do this whenever ExitFeeController's storage /// changes): mirror `forge inspect ExitFeeController storageLayout` -/// EXACTLY — every own variable (slots 251..270 today), in the same +/// EXACTLY — every own variable (slots 251..271 today), in the same /// order, with the same struct types (imported from /// IExitFeeController so the type definitions are byte-identical) — -/// then place the two packed uint128 fields at the FIRST previously -/// __gap slot and shrink __gap by 1 (30 -> 29). The mirror below is -/// current as of the security-perimeter delay-extension storage -/// (securityPerimeterEnabled / globalDelaySeconds / admin + bypass -/// tiers + passthrough registry + enumeration sets). +/// then place the two packed uint128 fields at the FIRST still-unused +/// __gap slot and shrink __gap by 1 (29 -> 28). The mirror below is +/// current as of the security-perimeter delay extension (admin alone in +/// its shipped slot, bypass tiers, passthrough registry, enumeration +/// sets, then the two delay scalars in a reclaimed gap slot). contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; @@ -53,9 +53,7 @@ contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; - // slot 257 (packed: bool@0, uint32@1, address@5) — DO NOT reorder. - bool public securityPerimeterEnabled; - uint32 public globalDelaySeconds; + // slot 257 — `admin` alone, exactly as the fee release shipped it. address public admin; // slots 258..270 @@ -70,15 +68,21 @@ contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) - // Two packed uint128 fields. Both go at slot 271 (the first slot - // previously inside __gap[30]). Solidity puts them at offset 0 and - // offset 16 of the SAME slot. The gap should shrink to __gap[29]. + // slot 271 (packed: bool@0, uint32@1, uint216@5) — delay scalars, slot + // fully consumed so an appended field starts at the next whole slot. + bool public securityPerimeterEnabled; + uint32 public globalDelaySeconds; + uint216 private __slot271Reserved; + + // Two packed uint128 fields. Both go at slot 272 (the first slot still + // inside __gap[29]) at offset 0 and offset 16 of the SAME slot — this is + // what closing slot 271 buys. The gap should shrink to __gap[28]. uint128 public newA; uint128 public newB; // __gap shrinks by exactly 1 slot (one slot reclaimed for the two - // packed uint128 fields): 30 -> 29. - uint256[29] private __gap; + // packed uint128 fields): 29 -> 28. + uint256[28] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } From d9680e0282b29173139536e9b3335ff8da650c80 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Tue, 18 Aug 2026 11:53:44 +0300 Subject: [PATCH 03/12] Keep contract comments to behaviour and constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage section had drifted into narrating how the layout came about — which release shipped which slot, why one placement was chosen over another, what a verification tool would or would not accept. None of that helps someone reading or integrating the contract, and it goes stale the moment it deploys. That material belongs in the spec repo, the commit log and the PR. The layout block now states the current layout as fact and keeps the forward-looking rule: new state consumes from __gap, nothing is reordered, and nothing is declared before `admin` or packed into the free bytes of its slot. Also drops __slot271Reserved. It consumed no slot of its own and guarded nothing about the present layout; it existed only so that a later upgrade could not pack a small field into the 27 spare bytes of slot 271 — writing into guaranteed-zero padding, which is safe, and which the layout checker rejects only out of conservatism. A storage variable that exists to satisfy a tool, and needs a paragraph of justification to explain itself, is worse than the spare bytes it was protecting. The upgrade-safety fixture now demonstrates a packed addition in a fresh slot instead. Layout unchanged where it matters: `admin` at slot 257 offset 0, delay scalars packed at 271, __gap[29] at 272..300. Checker still reports upgrade-safe against the recorded mainnet layout, the fixture harness passes all three scenarios, and the suite is green at 389 tests. --- src/ExitFeeController.sol | 57 ++++++--------------------- src/interfaces/IExitFeeController.sol | 2 +- test/fixtures/BadV3.sol | 1 - test/fixtures/GoodPackedV2.sol | 17 ++++---- 4 files changed, 20 insertions(+), 57 deletions(-) diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index 7d657ec..535a9f1 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -58,11 +58,9 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 255 _subProductKeys mapping head (enumeration index) // 256 _actorKeys mapping head (enumeration index) // - // 257 admin -- the FEE release already shipped this slot, so it - // is fixed: `admin` at offset 0, alone. The delay extension - // adds NOTHING beside it, even though 12 bytes are free. + // 257 admin (20 bytes, offset 0) -- alone; the remaining + // 12 bytes of the slot are intentionally unused. // - // ── Delay extension (added inside the slots __gap reserved) ── // 258 _surfaceBypass mapping head // 259 _subProductBypass mapping head // 260 _actorBypass mapping head @@ -77,25 +75,18 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // 269 _passthroughSurfaceIds._values (Bytes32Set array head) ┐ 2 slots // 270 _passthroughSurfaceIds._indexes (mapping head) ┘ // 271 securityPerimeterEnabled (1 byte) + globalDelaySeconds - // (4 bytes) + __slot271Reserved (27 bytes) -- one reclaimed - // gap slot, fully consumed so later upgrades start clean. + // (4 bytes) -- PACKED; 27 bytes of the slot are unused. // 272 .. 300 __gap[29] -- preserves the OZ-style 50-slot namespace // (50 - 21 own slots used). // - // Own slots: 6 fee (251 packed + 252..256) + 1 admin (257) + 14 delay - // (258..271) = 21, so __gap = 50 - 21 = 29 and the namespace still ends - // at 300. - // - // WHY the two delay scalars are NOT packed beside `admin`: they would fit - // (20 + 1 + 4 = 25 bytes), and the free bytes read as zero, which is the - // desired disabled-at-upgrade state. But slot 257 is NOT part of the gap - // the shipped fee layout reserved, and the upgrade-safety check admits new - // state only inside reclaimed gap slots. Spending one gap slot out of the - // thirty available is cheaper than relaxing that check. + // Own slots: 251 + 252..256 + 257 + 258..271 = 21, so __gap = 50 - 21 = 29 + // and the namespace ends at slot 300. // // Upgrades that add storage to THIS contract MUST consume from __gap and // reduce its length by exactly the number of slots added. They MUST NOT - // reorder, insert, or change the type of any preceding slot. + // reorder, insert, or change the type of any preceding slot. In + // particular, nothing may be declared before `admin`, and nothing may be + // packed into the free bytes of its slot. using EnumerableSet for EnumerableSet.AddressSet; using EnumerableSet for EnumerableSet.Bytes32Set; @@ -168,20 +159,12 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// MAY equal the owner -- nothing requires the two authorities to /// be distinct. Unset (`address(0)`) until the owner appoints one; /// while unset, `onlyAdminOrOwner` admits only the owner. - /// - /// DECLARED FIRST, ALONE IN ITS SLOT, and BEFORE any delay field: - /// `admin` already exists in the deployed proxy at slot 257 - /// offset 0. Packing anything ahead of it would shift it within - /// the slot and make the stored address unreadable. Nothing may be - /// inserted above this line. + /// @dev Occupies slot 257 at offset 0, alone. Its position is fixed: + /// nothing may be declared before it, and nothing may be packed + /// into the free bytes beside it. address public admin; - // ─── Delay extension storage ──────────────────────────────────────── - // - // Everything below is NEW state, taken from `__gap`. `admin` above keeps - // the slot it already occupies in the deployed proxy, and nothing is - // packed into the free bytes beside it: new state starts at the first - // slot the deployed layout reserved as gap. + // ─── Delay policy state ───────────────────────────────────────────── /// @dev Surface-tier delay bypass. Key: `surfaceId`. Value: /// `DelayBypassPolicy {active, bypass}`. Mirrors `_surfacePolicy`'s @@ -261,13 +244,6 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// loses the probe point). `passthroughSurfaceIds()` exposes it. EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; - // The two delay scalars are declared HERE, last, rather than beside - // `admin`. Declared next to `admin` they would pack into the free bytes of - // its slot -- safe in itself, but that slot is not part of the gap the - // deployed layout reserved, and the upgrade-safety check (rightly) only - // admits new state inside reclaimed gap slots. Declared here they share - // one clean gap slot instead, and the check passes unmodified. - /// @notice Global kill switch for the DELAY perimeter. Independent of /// `exitFeeEnabled`: turning fees off does NOT disable the /// perimeter, and a fee-inactive surface can still be delay-active. @@ -285,15 +261,6 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// calls the queue. uint32 public globalDelaySeconds; - /// @dev Closes slot 271. Without it the slot keeps 27 free bytes, and the - /// next field a future upgrade appends would pack into them — landing - /// in a slot that is NOT part of the gap this release reserves, which - /// the upgrade-safety check refuses. Reserving the remainder here - /// costs nothing (the slot is already spent) and lets every later - /// upgrade start cleanly at the next whole gap slot. - // aderyn-ignore-next-line(unused-state-variable) - uint216 private __slot271Reserved; - // aderyn-ignore-next-line(unused-state-variable) uint256[29] private __gap; diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index b382e3a..c7ee974 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -226,7 +226,7 @@ interface IExitFeeController { // ─── Admin ──────────────────────────────────────────────────────────── - /// @notice `onlyAdminOrOwner` since the core merge: + /// @notice `onlyAdminOrOwner`: /// the fee kill switch and receiver re-point are operational /// levers shared with the Admin guardian. Every other setter in /// this section is Owner-only. diff --git a/test/fixtures/BadV3.sol b/test/fixtures/BadV3.sol index 9939119..d879d03 100644 --- a/test/fixtures/BadV3.sol +++ b/test/fixtures/BadV3.sol @@ -78,7 +78,6 @@ contract BadV3 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { // __gap unchanged — this fixture adds NO storage; it only reorders a struct. bool public securityPerimeterEnabled; uint32 public globalDelaySeconds; - uint216 private __slot271Reserved; uint256[29] private __gap; diff --git a/test/fixtures/GoodPackedV2.sol b/test/fixtures/GoodPackedV2.sol index 9c500fe..e46c974 100644 --- a/test/fixtures/GoodPackedV2.sol +++ b/test/fixtures/GoodPackedV2.sol @@ -53,7 +53,7 @@ contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable mapping(bytes32 => EnumerableSet.AddressSet) internal _subProductKeys; mapping(bytes32 => EnumerableSet.AddressSet) internal _actorKeys; - // slot 257 — `admin` alone, exactly as the fee release shipped it. + // slot 257 — `admin` alone. address public admin; // slots 258..270 @@ -68,21 +68,18 @@ contract GoodPackedV2 is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable EnumerableSet.Bytes32Set internal _bypassSurfaceIds; // slots 267..268 (2 slots) EnumerableSet.Bytes32Set internal _passthroughSurfaceIds; // slots 269..270 (2 slots) - // slot 271 (packed: bool@0, uint32@1, uint216@5) — delay scalars, slot - // fully consumed so an appended field starts at the next whole slot. + // slot 271 (packed: bool@0, uint32@1) — the delay scalars. bool public securityPerimeterEnabled; uint32 public globalDelaySeconds; - uint216 private __slot271Reserved; - // Two packed uint128 fields. Both go at slot 272 (the first slot still - // inside __gap[29]) at offset 0 and offset 16 of the SAME slot — this is - // what closing slot 271 buys. The gap should shrink to __gap[28]. + // A full-slot field at 272, then two uint128 packed into slot 273 at + // offset 0 and offset 16. The gap shrinks by two slots, to __gap[27]. + uint256 public newFull; uint128 public newA; uint128 public newB; - // __gap shrinks by exactly 1 slot (one slot reclaimed for the two - // packed uint128 fields): 29 -> 28. - uint256[28] private __gap; + // __gap shrinks by exactly 2 slots: 29 -> 27. + uint256[27] private __gap; function _authorizeUpgrade(address) internal view override onlyOwner {} } From 4a874b88245b34d9b913e78b68c687c903d0ed6e Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 21 Aug 2026 01:29:00 +0300 Subject: [PATCH 04/12] Rename the perimeter surface names to their own namespace The core's half of the Phase 1 re-cut. Surface ids are keccak256 of the names the bootstrap script passes, so renaming the names moves every id -- which is the point: Phase 2 inherits them rather than re-bootstrapping a live controller off one set of ids onto another. SURFACE_* -> PERIMETER_SURFACE_* COLFEE_* -> PERIMETER_* (bootstrap env inputs, matching the delay branch) The env rename changes the bootstrap invocation: the rate and enable inputs are now PERIMETER_LENDING_LENDER_BPS, PERIMETER_LENDING_BORROWER_BPS, PERIMETER_ZERO_WITHDRAW_COLL_BPS, PERIMETER_ZERO_CLAIM_SURPLUS_BPS and PERIMETER_ENABLE_AT_DEPLOY. Every one stays required -- no silent defaults. PinnedIdentifiers pins all five ids to the literal 32 bytes they must hash to, the same values the lending and Zero repos pin. Four repos declare these names independently, so drift between them does not fail loudly: the controller resolves no policy and the fee stops being charged. 107 tests passing, unchanged from before the rename. --- README.md | 20 ++++----- script/04_BootstrapController.s.sol | 36 +++++++-------- script/99_UpgradeProxy.s.sol | 2 +- script/InspectController.s.sol | 12 ++--- src/ExitFeeController.sol | 8 ++-- src/interfaces/IExitFeeController.sol | 6 +-- test/invariant/ControllerHandler.sol | 8 ++-- test/unit/ExitFeeController.t.sol | 4 +- test/unit/PinnedIdentifiers.t.sol | 63 +++++++++++++++++++++++++++ tools/diff-storage-layouts.py | 2 +- 10 files changed, 112 insertions(+), 49 deletions(-) create mode 100644 test/unit/PinnedIdentifiers.t.sol diff --git a/README.md b/README.md index ae7c068..1716813 100644 --- a/README.md +++ b/README.md @@ -138,18 +138,18 @@ export EXIT_FEE_VAULT_PROXY= # Rates for the four surfaces that ship ON. All REQUIRED — a missing one reverts # the script rather than shipping a rate nobody chose. 0 does NOT mean "skip": # the surface is still written, active and free. -export COLFEE_LENDING_LENDER_BPS= -export COLFEE_LENDING_BORROWER_BPS= -export COLFEE_ZERO_WITHDRAW_COLL_BPS= -export COLFEE_ZERO_CLAIM_SURPLUS_BPS= -# SURFACE_AMM_REMOVE_LIQUIDITY has no consumer in this release and takes no env +export PERIMETER_LENDING_LENDER_BPS= +export PERIMETER_LENDING_BORROWER_BPS= +export PERIMETER_ZERO_WITHDRAW_COLL_BPS= +export PERIMETER_ZERO_CLAIM_SURPLUS_BPS= +# PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY has no consumer in this release and takes no env # var: the script writes it as (active=false, 0). Turning it on later is a single # setSurfacePolicy call from the owner. # MAINNET: keep this false. Enabling at deploy would turn the # system on while the deployer EOA still owns the proxies — enable via the governance # Safe only after the ownership handoff and the release gates in SIP-0094. # =true is for local/test chains only. -export COLFEE_ENABLE_AT_DEPLOY=false +export PERIMETER_ENABLE_AT_DEPLOY=false forge script script/04_BootstrapController.s.sol \ --rpc-url $RSK_RPC --broadcast --account deployer \ --sig "run(uint256)" @@ -259,17 +259,17 @@ The upgrade-safety check deliberately does **not** verify candidate-bytecode-vs- ## Home-repo integration -Perimeter Fee is consumed by three product repos. Each one copies the `IExitFeeController` interface file into its own tree on a `private/colfee` branch — **no git submodule** (the file-copy approach avoids submodule-pointer churn during private-branch development and audit): +Perimeter Fee is consumed by three product repos. Each one copies the `IExitFeeController` interface file into its own tree on a `private/perimeter` branch — **no git submodule** (the file-copy approach avoids submodule-pointer churn during private-branch development and audit): | Home repo | Pragma | Interface copy | Hook location | | ------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `Sovryn-smart-contracts` | 0.5.17 | `contracts/external/colfee/IExitFeeController.sol` ← copy of `src/interfaces/IExitFeeController.sol` | lending: `LoanTokenLogicShared` · loan/margin: `ModuleCommonFunctionalities` + `LoanClosingsShared` | +| `Sovryn-smart-contracts` | 0.5.17 | `contracts/external/perimeter/IExitFeeController.sol` ← copy of `src/interfaces/IExitFeeController.sol` | lending: `LoanTokenLogicShared` · loan/margin: `ModuleCommonFunctionalities` + `LoanClosingsShared` | | `zero-contracts` | 0.6.11 | same path ← copy of `src/interfaces/IExitFeeController.sol` | `BorrowerOperations` | | `oracle-based-amm` _(deferred to Phase 6)_ | 0.4.26 | same path ← copy of `src/interfaces/v0_4/IExitFeeController.sol` | `ConverterBase` | The 0.5+/0.6+/0.8 range pragma on the unified interface means Sovryn-smart-contracts and zero-contracts copy the same file; only AMM needs the structurally-different `v0_4/` outlier. -When the interface changes here, each home repo re-copies its respective file (with a provenance header pinning the colfee SHA) and runs `tools/check-abi-equivalence.sh` against the colfee source to confirm the v0_4 outlier still matches. +When the interface changes here, each home repo re-copies its respective file (with a provenance header pinning the perimeter SHA) and runs `tools/check-abi-equivalence.sh` against the perimeter source to confirm the v0_4 outlier still matches. --- @@ -288,7 +288,7 @@ When the interface changes here, each home repo re-copies its respective file (w **Phase 1 complete**: shared Perimeter Fee contracts (controller + vault) + interfaces + deploy/upgrade tooling. 99/99 tests passing (95 unit + 4 invariant). ABI-equivalence guard green across four compilers. The local/EOA deploy → finalize → upgrade-safety flow is smoke-tested; production Safe execution requires the Safe-aware artifact-refresh step noted above. -**Next**: Phase 2 (lending hooks in `Sovryn-smart-contracts-colfee`), Phase 3 (loan/margin hooks in same repo), Phase 4 (Zero hooks in `zero-contracts-colfee`). Phase 6 (AMM) deferred until proof gates pass. +**Next**: Phase 2 (lending hooks in `Sovryn-smart-contracts-perimeter`), Phase 3 (loan/margin hooks in same repo), Phase 4 (Zero hooks in `zero-contracts-perimeter`). Phase 6 (AMM) deferred until proof gates pass. --- diff --git a/script/04_BootstrapController.s.sol b/script/04_BootstrapController.s.sol index cd619ef..9d5e0a4 100644 --- a/script/04_BootstrapController.s.sol +++ b/script/04_BootstrapController.s.sol @@ -36,17 +36,17 @@ import {IExitFeeController} from "../src/interfaces/IExitFeeController.sol"; /// export EXIT_FEE_CONTROLLER_ADMIN=0x... # final owner /// export EXIT_FEE_OPERATIONAL_ADMIN=0x... # operational admin role /// export EXIT_FEE_VAULT_PROXY=0x... # fee receiver -/// export COLFEE_LENDING_LENDER_BPS=10 -/// export COLFEE_LENDING_BORROWER_BPS=10 -/// export COLFEE_ZERO_WITHDRAW_COLL_BPS=10 -/// export COLFEE_ZERO_CLAIM_SURPLUS_BPS=10 -/// export COLFEE_ENABLE_AT_DEPLOY=false # mainnet: false +/// export PERIMETER_LENDING_LENDER_BPS=10 +/// export PERIMETER_LENDING_BORROWER_BPS=10 +/// export PERIMETER_ZERO_WITHDRAW_COLL_BPS=10 +/// export PERIMETER_ZERO_CLAIM_SURPLUS_BPS=10 +/// export PERIMETER_ENABLE_AT_DEPLOY=false # mainnet: false /// /// forge script script/04_BootstrapController.s.sol \ /// --rpc-url $RSK_RPC --broadcast --account deployer \ /// --sig "run(uint256)" /// -/// Surface IDs are derived as `keccak256("COLFEE:")` per +/// Surface IDs are derived as `keccak256("PERIMETER:")` per /// `docs/SURFACE_REGISTRY.md`. /// /// ALL five registered surfaces are written here — the deploy @@ -90,11 +90,11 @@ contract BootstrapController is Script { // so a forgotten value stops the deploy instead of shipping a zero. address vaultProxy = vm.envAddress("EXIT_FEE_VAULT_PROXY"); require(vaultProxy != address(0), "EXIT_FEE_VAULT_PROXY must be set"); - uint256 lenderBps = vm.envUint("COLFEE_LENDING_LENDER_BPS"); - uint256 borrowerBps = vm.envUint("COLFEE_LENDING_BORROWER_BPS"); - uint256 zeroBps = vm.envUint("COLFEE_ZERO_WITHDRAW_COLL_BPS"); - uint256 surplusBps = vm.envUint("COLFEE_ZERO_CLAIM_SURPLUS_BPS"); - bool enableNow = vm.envBool("COLFEE_ENABLE_AT_DEPLOY"); + uint256 lenderBps = vm.envUint("PERIMETER_LENDING_LENDER_BPS"); + uint256 borrowerBps = vm.envUint("PERIMETER_LENDING_BORROWER_BPS"); + uint256 zeroBps = vm.envUint("PERIMETER_ZERO_WITHDRAW_COLL_BPS"); + uint256 surplusBps = vm.envUint("PERIMETER_ZERO_CLAIM_SURPLUS_BPS"); + bool enableNow = vm.envBool("PERIMETER_ENABLE_AT_DEPLOY"); console2.log("ExitFeeController @", proxy); console2.log(" chainId: ", chainId); @@ -113,20 +113,20 @@ contract BootstrapController is Script { // Writing every surface leaves the deploy transaction as a complete // statement of intent: no surface can be silently forgotten, and // "absent" is never mistaken for "deliberate". - _setSurface(controller, "SURFACE_LENDING_LENDER_WITHDRAW", lenderBps, true); - _setSurface(controller, "SURFACE_LENDING_BORROWER_WITHDRAW", borrowerBps, true); - _setSurface(controller, "SURFACE_ZERO_WITHDRAW_COLL", zeroBps, true); - _setSurface(controller, "SURFACE_ZERO_CLAIM_SURPLUS", surplusBps, true); + _setSurface(controller, "PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW", lenderBps, true); + _setSurface(controller, "PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW", borrowerBps, true); + _setSurface(controller, "PERIMETER_SURFACE_ZERO_WITHDRAW_COLL", zeroBps, true); + _setSurface(controller, "PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS", surplusBps, true); // AMM has no consumer in this release: written explicitly OFF so the // deploy log carries the decision rather than an absence. - _setSurface(controller, "SURFACE_AMM_REMOVE_LIQUIDITY", 0, false); + _setSurface(controller, "PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY", 0, false); // ─── 7. Global switch ──────────────────────────────────────────── if (enableNow) { controller.setExitFeeEnabled(true); console2.log("setExitFeeEnabled: true"); } else { - console2.log("setExitFeeEnabled: NOT enabled (COLFEE_ENABLE_AT_DEPLOY=false)"); + console2.log("setExitFeeEnabled: NOT enabled (PERIMETER_ENABLE_AT_DEPLOY=false)"); } // ─── 8. Appoint operational admin (BEFORE the handoff) ─────────── @@ -155,7 +155,7 @@ contract BootstrapController is Script { /// whether anything is charged. function _setSurface(ExitFeeController c, string memory name, uint256 rateBps, bool active) internal { require(rateBps <= MAX_BPS, "rateBps > MAX_BPS"); - bytes32 id = keccak256(abi.encodePacked("COLFEE:", name)); + bytes32 id = keccak256(abi.encodePacked("PERIMETER:", name)); c.setSurfacePolicy(id, IExitFeeController.RatePolicy({active: active, rateBps: uint16(rateBps)})); console2.log( string.concat("setSurfacePolicy ", name, active ? " (active)" : " (OFF - no consumer)"), rateBps diff --git a/script/99_UpgradeProxy.s.sol b/script/99_UpgradeProxy.s.sol index 9122064..53bbdab 100644 --- a/script/99_UpgradeProxy.s.sol +++ b/script/99_UpgradeProxy.s.sol @@ -6,7 +6,7 @@ import {console2} from "forge-std/console2.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; /// @title Upgrade a UUPS proxy -/// @notice Generic upgrade script for the ColFee proxies. Takes the proxy +/// @notice Generic upgrade script for the Perimeter proxies. Takes the proxy /// address and the new implementation address from env, calls /// `upgradeTo(newImpl)`, and emits the impl pointer change to the /// broadcast log. diff --git a/script/InspectController.s.sol b/script/InspectController.s.sol index 40a36ef..3419148 100644 --- a/script/InspectController.s.sol +++ b/script/InspectController.s.sol @@ -30,11 +30,11 @@ contract InspectController is Script { // aligned with docs/SURFACE_REGISTRY.md and the set that // 04_BootstrapController.s.sol writes. string[5] internal surfaceNames = [ - "SURFACE_LENDING_LENDER_WITHDRAW", - "SURFACE_LENDING_BORROWER_WITHDRAW", - "SURFACE_ZERO_WITHDRAW_COLL", - "SURFACE_ZERO_CLAIM_SURPLUS", - "SURFACE_AMM_REMOVE_LIQUIDITY" + "PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW", + "PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW", + "PERIMETER_SURFACE_ZERO_WITHDRAW_COLL", + "PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS", + "PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY" ]; // EIP-1967 implementation storage slot. @@ -79,7 +79,7 @@ contract InspectController is Script { } function _printSurface(ExitFeeController c, string memory name) internal view { - bytes32 id = keccak256(abi.encodePacked("COLFEE:", name)); + bytes32 id = keccak256(abi.encodePacked("PERIMETER:", name)); console2.log(name); console2.log(" id: ", vm.toString(id)); diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index aae1f13..6d4d461 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -9,7 +9,7 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet import {IExitFeeController} from "./interfaces/IExitFeeController.sol"; /// @title ExitFeeController -/// @notice Governance-owned resolver for ExitFee (ColFee) policy. Three +/// @notice Governance-owned resolver for ExitFee (Perimeter) policy. Three /// RatePolicy tiers per surface: actor → sub-product → surface. /// Most-specific *active* entry wins; the surface itself gates the /// surface (if its `active` flag is false, overrides do not apply). @@ -33,7 +33,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // A `surfaceId` is an opaque `bytes32` naming an operation kind. The // controller stores it as-is and never inspects or decodes it -- the // contract is product/asset-agnostic. The off-chain naming convention - // is `keccak256("COLFEE:")`; nothing on-chain enforces or depends + // is `keccak256("PERIMETER:")`; nothing on-chain enforces or depends // on it. Adding a surface is therefore an owner-only // `setSurfacePolicy(newId, policy)` call -- never a contract upgrade. @@ -259,7 +259,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain - // naming convention is `keccak256("COLFEE:")`; the controller + // naming convention is `keccak256("PERIMETER:")`; the controller // stores the bytes32 as-is and never inspects the name. /// @notice Configure (or update) the surface tier for `surfaceId`. @@ -267,7 +267,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// the whole surface is off and sub-product / actor overrides /// are ignored. Overwriting is idempotent. /// @param surfaceId Opaque operation-kind identifier; - /// `keccak256("COLFEE:")` by convention. + /// `keccak256("PERIMETER:")` by convention. /// @param policy Active flag + rate in basis points (`rateBps <= MAX_BPS`). function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external onlyOwner { if (policy.rateBps > MAX_BPS) revert RateExceedsMaxBps(policy.rateBps); diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index 05c142b..c1cc9a6 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -18,15 +18,15 @@ pragma solidity >=0.5.17 <0.9.0; pragma experimental ABIEncoderV2; /// @title IExitFeeController -/// @notice Cross-pragma interface for the ExitFee (ColFee) controller. One +/// @notice Cross-pragma interface for the ExitFee (Perimeter) controller. One /// file for every consumer on 0.5.17, 0.6.11, and 0.8.20. Consumers /// on 0.4.26 use the structurally-different variant in `v0_4/`, /// which must stay ABI-identical to this file. interface IExitFeeController { // ─── Types ──────────────────────────────────────────────────────────── - /// @notice Reason a `ColFeeSkipped` event was emitted instead of an - /// `ColFeeApplied`. NONE covers honest paths (positive charge, + /// @notice Reason a `PerimeterSkipped` event was emitted instead of an + /// `PerimeterApplied`. NONE covers honest paths (positive charge, /// dust, or actor-exemption); the rest cover off-state outcomes. enum SkipReason { NONE, // Controller computed an honest quote (charge / dust / zero-rate). diff --git a/test/invariant/ControllerHandler.sol b/test/invariant/ControllerHandler.sol index 73fd25c..e2c82d2 100644 --- a/test/invariant/ControllerHandler.sol +++ b/test/invariant/ControllerHandler.sol @@ -90,10 +90,10 @@ contract ControllerHandler { constructor(ExitFeeController controller_) { controller = controller_; - _surfaces[0] = keccak256("COLFEE:SURFACE_LENDING_LENDER_WITHDRAW"); - _surfaces[1] = keccak256("COLFEE:SURFACE_LENDING_BORROWER_WITHDRAW"); - _surfaces[2] = keccak256("COLFEE:SURFACE_ZERO_WITHDRAW_COLL"); - _surfaces[3] = keccak256("COLFEE:SURFACE_AMM_REMOVE_LIQUIDITY"); + _surfaces[0] = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + _surfaces[1] = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"); + _surfaces[2] = keccak256("PERIMETER:PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); + _surfaces[3] = keccak256("PERIMETER:PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"); for (uint256 i = 0; i < 8; ++i) { _addrs[i] = address(uint160(0xA00 + i + 1)); // all non-zero diff --git a/test/unit/ExitFeeController.t.sol b/test/unit/ExitFeeController.t.sol index ffe718c..f9ccff9 100644 --- a/test/unit/ExitFeeController.t.sol +++ b/test/unit/ExitFeeController.t.sol @@ -36,8 +36,8 @@ contract ExitFeeControllerTest is Test { address constant OTHER = address(0xBEEF); address constant CAFE = address(0xCAFE); - bytes32 constant SURFACE = keccak256("COLFEE:SURFACE_LENDING_LENDER_WITHDRAW"); - bytes32 constant SURFACE_OTHER = keccak256("COLFEE:SURFACE_ZERO_WITHDRAW_COLL"); + bytes32 constant SURFACE = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + bytes32 constant SURFACE_OTHER = keccak256("PERIMETER:PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); function setUp() public { ExitFeeController impl = new ExitFeeController(); diff --git a/test/unit/PinnedIdentifiers.t.sol b/test/unit/PinnedIdentifiers.t.sol new file mode 100644 index 0000000..20a2cfb --- /dev/null +++ b/test/unit/PinnedIdentifiers.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +import {Test} from "forge-std/Test.sol"; + +/// @title Pinned perimeter surface ids +/// @notice A surface id is `keccak256` of its name, so the name IS the value. +/// The same five names are declared independently here, in the lending +/// repo, in Zero and in the dapp; a one-character drift in any of them +/// resolves no policy and silently stops the fee rather than failing. +/// Each name is pinned to the literal 32 bytes it must hash to, so a +/// bulk rename cannot rewrite the name and its assertion together. +/// @dev A diff here means a redeploy and a re-bootstrap of the controller, +/// never a test edit. +contract PinnedIdentifiersTest is Test { + function testLenderWithdrawSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"), + 0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe + ); + } + + function testBorrowerWithdrawSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"), + 0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018 + ); + } + + function testZeroWithdrawCollSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"), + 0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5 + ); + } + + function testZeroClaimSurplusSurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_ZERO_CLAIM_SURPLUS"), + 0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be + ); + } + + function testAmmRemoveLiquiditySurfaceId() public pure { + assertEq( + keccak256("PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"), + 0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2 + ); + } + + /// @notice The Phase-1 names must be gone: an id derived from one of them + /// points at a policy slot the re-cut controller never writes. + function testStaleNamesDoNotCollide() public pure { + assertTrue( + keccak256("SURFACE_LENDING_LENDER_WITHDRAW") != + keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW") + ); + assertTrue( + keccak256("SURFACE_ZERO_WITHDRAW_COLL") != + keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL") + ); + } +} diff --git a/tools/diff-storage-layouts.py b/tools/diff-storage-layouts.py index 340fe03..7e71969 100755 --- a/tools/diff-storage-layouts.py +++ b/tools/diff-storage-layouts.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Storage-layout upgrade-compatibility check for the ColFee proxies. +Storage-layout upgrade-compatibility check for the Perimeter proxies. Usage: diff-storage-layouts.py From 310a8c5d578ce10843bc5a92adbc7b1ee13b9ca3 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 21 Aug 2026 01:43:07 +0300 Subject: [PATCH 05/12] Derive surface ids from the bare name, not a prefix The bootstrap and inspect scripts hashed keccak256(abi.encodePacked("COLFEE:", name)). The rename swept that literal to "PERIMETER:" and left the concatenation in place, so every id would have been keccak256("PERIMETER:" + "PERIMETER_SURFACE_...") -- matching neither what Phase 1 deployed nor what the consumer contracts derive. The namespace is part of the name now, so both scripts hash the name alone and the ids agree with the lending and Zero consumers byte for byte. --- script/04_BootstrapController.s.sol | 4 ++-- script/InspectController.s.sol | 2 +- src/ExitFeeController.sol | 6 +++--- test/invariant/ControllerHandler.sol | 8 ++++---- test/unit/ExitFeeController.t.sol | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/script/04_BootstrapController.s.sol b/script/04_BootstrapController.s.sol index 9d5e0a4..91e16d7 100644 --- a/script/04_BootstrapController.s.sol +++ b/script/04_BootstrapController.s.sol @@ -46,7 +46,7 @@ import {IExitFeeController} from "../src/interfaces/IExitFeeController.sol"; /// --rpc-url $RSK_RPC --broadcast --account deployer \ /// --sig "run(uint256)" /// -/// Surface IDs are derived as `keccak256("PERIMETER:")` per +/// Surface IDs are derived as `keccak256("")` per /// `docs/SURFACE_REGISTRY.md`. /// /// ALL five registered surfaces are written here — the deploy @@ -155,7 +155,7 @@ contract BootstrapController is Script { /// whether anything is charged. function _setSurface(ExitFeeController c, string memory name, uint256 rateBps, bool active) internal { require(rateBps <= MAX_BPS, "rateBps > MAX_BPS"); - bytes32 id = keccak256(abi.encodePacked("PERIMETER:", name)); + bytes32 id = keccak256(bytes(name)); c.setSurfacePolicy(id, IExitFeeController.RatePolicy({active: active, rateBps: uint16(rateBps)})); console2.log( string.concat("setSurfacePolicy ", name, active ? " (active)" : " (OFF - no consumer)"), rateBps diff --git a/script/InspectController.s.sol b/script/InspectController.s.sol index 3419148..9a0ecf1 100644 --- a/script/InspectController.s.sol +++ b/script/InspectController.s.sol @@ -79,7 +79,7 @@ contract InspectController is Script { } function _printSurface(ExitFeeController c, string memory name) internal view { - bytes32 id = keccak256(abi.encodePacked("PERIMETER:", name)); + bytes32 id = keccak256(bytes(name)); console2.log(name); console2.log(" id: ", vm.toString(id)); diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index 6d4d461..d52334f 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -33,7 +33,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // A `surfaceId` is an opaque `bytes32` naming an operation kind. The // controller stores it as-is and never inspects or decodes it -- the // contract is product/asset-agnostic. The off-chain naming convention - // is `keccak256("PERIMETER:")`; nothing on-chain enforces or depends + // is `keccak256("")`; nothing on-chain enforces or depends // on it. Adding a surface is therefore an owner-only // `setSurfacePolicy(newId, policy)` call -- never a contract upgrade. @@ -259,7 +259,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain - // naming convention is `keccak256("PERIMETER:")`; the controller + // naming convention is `keccak256("")`; the controller // stores the bytes32 as-is and never inspects the name. /// @notice Configure (or update) the surface tier for `surfaceId`. @@ -267,7 +267,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// the whole surface is off and sub-product / actor overrides /// are ignored. Overwriting is idempotent. /// @param surfaceId Opaque operation-kind identifier; - /// `keccak256("PERIMETER:")` by convention. + /// `keccak256("")` by convention. /// @param policy Active flag + rate in basis points (`rateBps <= MAX_BPS`). function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external onlyOwner { if (policy.rateBps > MAX_BPS) revert RateExceedsMaxBps(policy.rateBps); diff --git a/test/invariant/ControllerHandler.sol b/test/invariant/ControllerHandler.sol index e2c82d2..b8589b7 100644 --- a/test/invariant/ControllerHandler.sol +++ b/test/invariant/ControllerHandler.sol @@ -90,10 +90,10 @@ contract ControllerHandler { constructor(ExitFeeController controller_) { controller = controller_; - _surfaces[0] = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); - _surfaces[1] = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"); - _surfaces[2] = keccak256("PERIMETER:PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); - _surfaces[3] = keccak256("PERIMETER:PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"); + _surfaces[0] = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + _surfaces[1] = keccak256("PERIMETER_SURFACE_LENDING_BORROWER_WITHDRAW"); + _surfaces[2] = keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); + _surfaces[3] = keccak256("PERIMETER_SURFACE_AMM_REMOVE_LIQUIDITY"); for (uint256 i = 0; i < 8; ++i) { _addrs[i] = address(uint160(0xA00 + i + 1)); // all non-zero diff --git a/test/unit/ExitFeeController.t.sol b/test/unit/ExitFeeController.t.sol index f9ccff9..b28a6cb 100644 --- a/test/unit/ExitFeeController.t.sol +++ b/test/unit/ExitFeeController.t.sol @@ -36,8 +36,8 @@ contract ExitFeeControllerTest is Test { address constant OTHER = address(0xBEEF); address constant CAFE = address(0xCAFE); - bytes32 constant SURFACE = keccak256("PERIMETER:PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); - bytes32 constant SURFACE_OTHER = keccak256("PERIMETER:PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); + bytes32 constant SURFACE = keccak256("PERIMETER_SURFACE_LENDING_LENDER_WITHDRAW"); + bytes32 constant SURFACE_OTHER = keccak256("PERIMETER_SURFACE_ZERO_WITHDRAW_COLL"); function setUp() public { ExitFeeController impl = new ExitFeeController(); From c9548dba52560b85d671aa7202f3314dce421067 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 21 Aug 2026 02:04:47 +0300 Subject: [PATCH 06/12] Leave the deployed controller's source byte-for-byte reproducible The rename swept three comments in ExitFeeController and its interface. The controller is already deployed, already verified, and its ownership transfer to the Exchequer multisig is mid-flight -- Phase 1's re-cut does not redeploy it, because the controller encodes no surface names and stores policy as storage keyed by id. Comments are covered by the metadata hash, so those three edits changed the runtime bytecode: identical body, different tail. The branch would no longer reproduce what is on chain, and a later verification against the release source would come back mismatched for a contract nobody had touched. The word survives in three comments of a live contract until Phase 2 upgrades it for the delay, which redeploys and re-verifies anyway. The scripts and tests keep the rename -- that is where the surface names actually live. --- src/ExitFeeController.sol | 8 ++++---- src/interfaces/IExitFeeController.sol | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index d52334f..aae1f13 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -9,7 +9,7 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet import {IExitFeeController} from "./interfaces/IExitFeeController.sol"; /// @title ExitFeeController -/// @notice Governance-owned resolver for ExitFee (Perimeter) policy. Three +/// @notice Governance-owned resolver for ExitFee (ColFee) policy. Three /// RatePolicy tiers per surface: actor → sub-product → surface. /// Most-specific *active* entry wins; the surface itself gates the /// surface (if its `active` flag is false, overrides do not apply). @@ -33,7 +33,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // A `surfaceId` is an opaque `bytes32` naming an operation kind. The // controller stores it as-is and never inspects or decodes it -- the // contract is product/asset-agnostic. The off-chain naming convention - // is `keccak256("")`; nothing on-chain enforces or depends + // is `keccak256("COLFEE:")`; nothing on-chain enforces or depends // on it. Adding a surface is therefore an owner-only // `setSurfacePolicy(newId, policy)` call -- never a contract upgrade. @@ -259,7 +259,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain - // naming convention is `keccak256("")`; the controller + // naming convention is `keccak256("COLFEE:")`; the controller // stores the bytes32 as-is and never inspects the name. /// @notice Configure (or update) the surface tier for `surfaceId`. @@ -267,7 +267,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// the whole surface is off and sub-product / actor overrides /// are ignored. Overwriting is idempotent. /// @param surfaceId Opaque operation-kind identifier; - /// `keccak256("")` by convention. + /// `keccak256("COLFEE:")` by convention. /// @param policy Active flag + rate in basis points (`rateBps <= MAX_BPS`). function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external onlyOwner { if (policy.rateBps > MAX_BPS) revert RateExceedsMaxBps(policy.rateBps); diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index c1cc9a6..05c142b 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -18,15 +18,15 @@ pragma solidity >=0.5.17 <0.9.0; pragma experimental ABIEncoderV2; /// @title IExitFeeController -/// @notice Cross-pragma interface for the ExitFee (Perimeter) controller. One +/// @notice Cross-pragma interface for the ExitFee (ColFee) controller. One /// file for every consumer on 0.5.17, 0.6.11, and 0.8.20. Consumers /// on 0.4.26 use the structurally-different variant in `v0_4/`, /// which must stay ABI-identical to this file. interface IExitFeeController { // ─── Types ──────────────────────────────────────────────────────────── - /// @notice Reason a `PerimeterSkipped` event was emitted instead of an - /// `PerimeterApplied`. NONE covers honest paths (positive charge, + /// @notice Reason a `ColFeeSkipped` event was emitted instead of an + /// `ColFeeApplied`. NONE covers honest paths (positive charge, /// dust, or actor-exemption); the rest cover off-state outcomes. enum SkipReason { NONE, // Controller computed an honest quote (charge / dust / zero-rate). From b8e056dc959803e408f49334bb632e6fa23ba887 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 21 Aug 2026 10:44:03 +0300 Subject: [PATCH 07/12] Carry the rename into the core contracts after all The earlier revert kept src/ byte-reproducible against the deployed controller, on the assumption that Phase 1 would not redeploy it. Tyrone's call: the deployed contracts are dropped and redeployed with the back-port, so there is nothing on chain left to reproduce and no reason to leave the old product name sitting in a contract that is about to be replaced. Consequences, which belong in the release plan rather than here: the vault and controller get new addresses, the Exchequer acceptOwnership transactions 2207 and 2208 point at contracts that will not be the release, and everything that pins a controller address -- the dapp, the SIP builders, the runbook anchors -- re-pins. --- src/ExitFeeController.sol | 8 ++++---- src/interfaces/IExitFeeController.sol | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index aae1f13..d52334f 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -9,7 +9,7 @@ import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet import {IExitFeeController} from "./interfaces/IExitFeeController.sol"; /// @title ExitFeeController -/// @notice Governance-owned resolver for ExitFee (ColFee) policy. Three +/// @notice Governance-owned resolver for ExitFee (Perimeter) policy. Three /// RatePolicy tiers per surface: actor → sub-product → surface. /// Most-specific *active* entry wins; the surface itself gates the /// surface (if its `active` flag is false, overrides do not apply). @@ -33,7 +33,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // A `surfaceId` is an opaque `bytes32` naming an operation kind. The // controller stores it as-is and never inspects or decodes it -- the // contract is product/asset-agnostic. The off-chain naming convention - // is `keccak256("COLFEE:")`; nothing on-chain enforces or depends + // is `keccak256("")`; nothing on-chain enforces or depends // on it. Adding a surface is therefore an owner-only // `setSurfacePolicy(newId, policy)` call -- never a contract upgrade. @@ -259,7 +259,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable // ─── Admin: policy setters ────────────────────────────────────────── // // `surfaceId` is an opaque operation-kind identifier. The off-chain - // naming convention is `keccak256("COLFEE:")`; the controller + // naming convention is `keccak256("")`; the controller // stores the bytes32 as-is and never inspects the name. /// @notice Configure (or update) the surface tier for `surfaceId`. @@ -267,7 +267,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// the whole surface is off and sub-product / actor overrides /// are ignored. Overwriting is idempotent. /// @param surfaceId Opaque operation-kind identifier; - /// `keccak256("COLFEE:")` by convention. + /// `keccak256("")` by convention. /// @param policy Active flag + rate in basis points (`rateBps <= MAX_BPS`). function setSurfacePolicy(bytes32 surfaceId, RatePolicy calldata policy) external onlyOwner { if (policy.rateBps > MAX_BPS) revert RateExceedsMaxBps(policy.rateBps); diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index 05c142b..c1cc9a6 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -18,15 +18,15 @@ pragma solidity >=0.5.17 <0.9.0; pragma experimental ABIEncoderV2; /// @title IExitFeeController -/// @notice Cross-pragma interface for the ExitFee (ColFee) controller. One +/// @notice Cross-pragma interface for the ExitFee (Perimeter) controller. One /// file for every consumer on 0.5.17, 0.6.11, and 0.8.20. Consumers /// on 0.4.26 use the structurally-different variant in `v0_4/`, /// which must stay ABI-identical to this file. interface IExitFeeController { // ─── Types ──────────────────────────────────────────────────────────── - /// @notice Reason a `ColFeeSkipped` event was emitted instead of an - /// `ColFeeApplied`. NONE covers honest paths (positive charge, + /// @notice Reason a `PerimeterSkipped` event was emitted instead of an + /// `PerimeterApplied`. NONE covers honest paths (positive charge, /// dust, or actor-exemption); the rest cover off-state outcomes. enum SkipReason { NONE, // Controller computed an honest quote (charge / dust / zero-rate). From 55acf50adcbba3d3a9f3989459a0faaeee70d4c7 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 21 Aug 2026 17:21:01 +0300 Subject: [PATCH 08/12] Name the events the interface actually declares The un-revert that carried the rename back into src/ also restored a NatSpec line naming PerimeterSkipped and PerimeterApplied. The events are ExitFeeSkipped and ExitFeeApplied; the Perimeter* names belong to Zero's Echidna harness. Zero's vendored copy of this interface was already corrected -- this is the same fix on the original. --- src/interfaces/IExitFeeController.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index c1cc9a6..89a63e7 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -25,8 +25,8 @@ pragma experimental ABIEncoderV2; interface IExitFeeController { // ─── Types ──────────────────────────────────────────────────────────── - /// @notice Reason a `PerimeterSkipped` event was emitted instead of an - /// `PerimeterApplied`. NONE covers honest paths (positive charge, + /// @notice Reason a `ExitFeeSkipped` event was emitted instead of an + /// `ExitFeeApplied`. NONE covers honest paths (positive charge, /// dust, or actor-exemption); the rest cover off-state outcomes. enum SkipReason { NONE, // Controller computed an honest quote (charge / dust / zero-rate). From dcc9033b75b50cd66522d686ea8478910949fcd4 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 28 Aug 2026 00:35:51 +0300 Subject: [PATCH 09/12] ops: point the block-exits output at the multisig submission path The printed (to, value, data) triple already matched submitTransaction's signature; the output now says where to take it - Blockscout's read/write tab on the Admin multisig, method 20, value 0 - and that the threshold confirmation executes the call itself. --- script/07_BlockExits.s.sol | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/script/07_BlockExits.s.sol b/script/07_BlockExits.s.sol index 7071bcf..5346682 100644 --- a/script/07_BlockExits.s.sol +++ b/script/07_BlockExits.s.sol @@ -343,11 +343,20 @@ contract BlockExits is Script { } function _emitCalldata(bytes memory data) internal view { - console2.log("--- submit to the queue from the Admin Safe ---"); + console2.log("--- submit from the Admin multisig ---"); console2.log("to :", address(queue)); console2.log("value: 0"); console2.log("data :", vm.toString(data)); console2.log(""); + console2.log("Submit via the multisig's Read/Write contract tab on Blockscout:"); + console2.log( + " https://rootstock.blockscout.com/address/?tab=read_write_contract" + ); + console2.log(" method 20. submitTransaction: destination = `to` above, value = 0,"); + console2.log(" data = the hex above. Simulate first, then Write. Further owners"); + console2.log(" confirm the emitted transactionId via method 4. confirmTransaction;"); + console2.log(" the threshold confirmation executes the call in the same transaction."); + console2.log(""); console2.log("--- then confirm the result ---"); console2.log("BLOCK_ACTION=verify forge script script/07_BlockExits.s.sol --rpc-url $RPC"); } From 2f6c0835e2df9e5d2c795b2d4ba16aa417b372ad Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 28 Aug 2026 01:00:31 +0300 Subject: [PATCH 10/12] ops: extend the block console to the controller kill switch disable-perimeter / enable-perimeter preview and print the calldata for setSecurityPerimeterEnabled on the controller, with the same rules as the queue levers: read-only, no-op guard, submit-from-multisig output. The disable wording states what it is - a liveness escape that makes every charged exit pay straight out and releases nothing already escrowed - so it cannot be mistaken for an incident lever. _emitCalldata now takes the target address since the two levers submit to different contracts. Suite 416 -> 420. --- script/07_BlockExits.s.sol | 60 ++++++++++++++++++++++++++++++++++---- test/unit/BlockExits.t.sol | 47 ++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/script/07_BlockExits.s.sol b/script/07_BlockExits.s.sol index 5346682..d931a94 100644 --- a/script/07_BlockExits.s.sol +++ b/script/07_BlockExits.s.sol @@ -5,6 +5,7 @@ import {Script} from "forge-std/Script.sol"; import {console2} from "forge-std/console2.sol"; import {ExitDelayQueue} from "../src/ExitDelayQueue.sol"; +import {ExitFeeController} from "../src/ExitFeeController.sol"; import {IExitDelayQueue} from "../src/interfaces/IExitDelayQueue.sol"; /// @title Block Exits - emergency stop for queued withdrawals @@ -50,7 +51,9 @@ import {IExitDelayQueue} from "../src/interfaces/IExitDelayQueue.sol"; /// @dev Usage: /// /// export EXIT_DELAY_QUEUE=0x... # the queue (required) +/// export EXIT_FEE_CONTROLLER=0x... # controller; only the two kill-switch actions need it /// export BLOCK_ACTION=freeze # freeze|blacklist|unfreeze|unblacklist|pause|unpause|verify +/// # |disable-perimeter|enable-perimeter (controller kill switch) /// /// # exactly one of these two for freeze/blacklist; actors only for the clears: /// export BLOCK_ACTORS=0xaaa,0xbbb # addresses to block or clear @@ -65,9 +68,14 @@ import {IExitDelayQueue} from "../src/interfaces/IExitDelayQueue.sol"; /// re-run with BLOCK_ACTION=verify to confirm the resulting states. contract BlockExits is Script { ExitDelayQueue internal queue; + ExitFeeController internal controller; function run() external { _init(vm.envAddress("EXIT_DELAY_QUEUE")); + address ctrl = vm.envOr("EXIT_FEE_CONTROLLER", address(0)); + if (ctrl != address(0)) { + _initController(ctrl); + } _dispatch( vm.envString("BLOCK_ACTION"), vm.envOr("BLOCK_ACTORS", ",", new address[](0)), @@ -81,6 +89,10 @@ contract BlockExits is Script { queue = ExitDelayQueue(payable(q)); } + function _initController(address c) internal { + controller = ExitFeeController(c); + } + /// @dev Every input is an explicit argument so the decision logic is /// driveable without process-global env, which forge does not isolate /// between parallel tests. @@ -114,9 +126,13 @@ contract BlockExits is Script { _clear(false, actors, ids); } else if (a == keccak256("verify")) { _verify(actors, ids, freezeReceiver); + } else if (a == keccak256("disable-perimeter")) { + _killSwitch(false); + } else if (a == keccak256("enable-perimeter")) { + _killSwitch(true); } else { revert( - "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify" + "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify, disable-perimeter, enable-perimeter" ); } } @@ -133,7 +149,40 @@ contract BlockExits is Script { ? "Halts executeExit and recoverStuckExit for EVERYONE. New exits keep escrowing." : "Resumes executeExit and recoverStuckExit. Per-actor blocks are unaffected." ); - _emitCalldata(abi.encodeCall(IExitDelayQueue.setSecurityPerimeterPaused, (on))); + _emitCalldata(address(queue), abi.encodeCall(IExitDelayQueue.setSecurityPerimeterPaused, (on))); + } + + // --- Controller kill switch ----------------------------------------- + + /// @dev The OPPOSITE lever to everything else in this script: disabling + /// the perimeter makes every charged exit pay straight out - no fee, + /// no delay, nothing escrows. It is a LIVENESS escape for a broken + /// perimeter, not an incident response; during an attack it is the + /// last thing to touch. Funds already escrowed are NOT released by + /// it - they stay in the queue behind their own holds and blocks. + function _killSwitch(bool enabled) internal view { + require( + address(controller) != address(0), + "set EXIT_FEE_CONTROLLER for disable-perimeter / enable-perimeter" + ); + console2.log("controller :", address(controller)); + console2.log("controller admin :", controller.admin()); + console2.log("controller owner :", controller.owner()); + bool current = controller.securityPerimeterEnabled(); + console2.log("perimeter enabled :", current); + if (current == enabled) { + console2.log("ALREADY in the requested state - nothing to submit."); + return; + } + console2.log( + enabled + ? "Re-arms the perimeter: every active surface charges and escrows again." + : "LIVENESS ESCAPE: every charged exit pays straight out - no fee, no delay, nothing escrows. Already-escrowed funds stay held in the queue." + ); + _emitCalldata( + address(controller), + abi.encodeCall(ExitFeeController.setSecurityPerimeterEnabled, (enabled)) + ); } // --- Per-actor block ------------------------------------------------ @@ -206,7 +255,7 @@ contract BlockExits is Script { if (changing == 0) { console2.log("NOTE: no state changes. The call still succeeds and re-emits AccountBlocked."); } - _emitCalldata(data); + _emitCalldata(address(queue), data); } // --- Per-actor clear ------------------------------------------------ @@ -246,6 +295,7 @@ contract BlockExits is Script { ); _emitCalldata( + address(queue), unfreezing ? abi.encodeWithSignature("unfreeze(address[])", actors) : abi.encodeWithSignature("unblacklist(address[])", actors) @@ -342,9 +392,9 @@ contract BlockExits is Script { return keccak256(bytes(reason)); } - function _emitCalldata(bytes memory data) internal view { + function _emitCalldata(address to, bytes memory data) internal view { console2.log("--- submit from the Admin multisig ---"); - console2.log("to :", address(queue)); + console2.log("to :", to); console2.log("value: 0"); console2.log("data :", vm.toString(data)); console2.log(""); diff --git a/test/unit/BlockExits.t.sol b/test/unit/BlockExits.t.sol index 8d4f4b0..74f54da 100644 --- a/test/unit/BlockExits.t.sol +++ b/test/unit/BlockExits.t.sol @@ -6,6 +6,7 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {ExitDelayQueue} from "../../src/ExitDelayQueue.sol"; +import {ExitFeeController} from "../../src/ExitFeeController.sol"; import {IExitDelayQueue} from "../../src/interfaces/IExitDelayQueue.sol"; import {BlockExits} from "../../script/07_BlockExits.s.sol"; @@ -57,6 +58,10 @@ contract BlockExitsHarness is BlockExits { _init(q); } + function initController(address c) external { + _initController(c); + } + function dispatch( string memory action, address[] memory actors, @@ -260,7 +265,7 @@ contract BlockExitsTest is Test { function test_unknown_action_is_rejected() public { vm.expectRevert( bytes( - "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify" + "BLOCK_ACTION must be one of: freeze, blacklist, unfreeze, unblacklist, pause, unpause, verify, disable-perimeter, enable-perimeter" ) ); script.dispatch("halt", _addrs(ORIG), _noIds(), false, ""); @@ -302,4 +307,44 @@ contract BlockExitsTest is Test { vm.expectRevert(bytes("set BLOCK_ACTORS or BLOCK_REQUEST_IDS")); script.dispatch("verify", _noAddrs(), _noIds(), false, ""); } + + // --- controller kill switch ----------------------------------------- + + function _deployController() internal returns (ExitFeeController ctrl) { + ExitFeeController impl = new ExitFeeController(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), abi.encodeWithSelector(ExitFeeController.initialize.selector, address(0)) + ); + ctrl = ExitFeeController(address(proxy)); + } + + function test_kill_switch_requires_the_controller_address() public { + vm.expectRevert(bytes("set EXIT_FEE_CONTROLLER for disable-perimeter / enable-perimeter")); + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + } + + function test_disable_perimeter_previews_when_enabled() public { + ExitFeeController ctrl = _deployController(); + ctrl.setSecurityPerimeterEnabled(true); + script.initController(address(ctrl)); + // preview only - the on-chain state must be untouched afterwards + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + assertTrue(ctrl.securityPerimeterEnabled(), "preview must not change state"); + } + + function test_disable_perimeter_refuses_nothing_to_submit_silently() public { + ExitFeeController ctrl = _deployController(); + script.initController(address(ctrl)); + // already disabled: the preview reports ALREADY and emits no calldata, + // and state stays untouched + script.dispatch("disable-perimeter", _noAddrs(), _noIds(), false, ""); + assertFalse(ctrl.securityPerimeterEnabled()); + } + + function test_enable_perimeter_previews_when_disabled() public { + ExitFeeController ctrl = _deployController(); + script.initController(address(ctrl)); + script.dispatch("enable-perimeter", _noAddrs(), _noIds(), false, ""); + assertFalse(ctrl.securityPerimeterEnabled(), "preview must not change state"); + } } From 980ea8f8c07cce157b0fb84982ed3522ed1e8a38 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Fri, 28 Aug 2026 01:19:35 +0300 Subject: [PATCH 11/12] review cycle 1: fix recovery-gas redirect, blacklist provenance, destination guards, go-live gate Contract correctness: - recoverStuckExit gave the stored-receiver payout 63/64 of remaining gas, so a caller could pick a gas limit that out-of-gases it (caught as a bounce) while the retained 1/64 completed the altReceiver payout -- redirecting a healthy exit. The attempt now runs under a fixed gas budget the caller must cover, so an under-funded call reverts instead of redirecting. - A plain freeze on an already-blacklisted address zeroed its recorded trigger and re-emitted with reason 0, erasing blacklist provenance. A freeze carrying no evidence now leaves the blacklist trigger/reason intact. - resolveBySIP and setRecoveryRoute lacked the this/token/WRBTC destination guard recoverStuckExit already enforces; a fat-fingered destination could trap the escrow. Guard added to both. Go-live and observability: - 06_VerifyActivation now asserts the queue is not left paused before enabling the perimeter -- a pause set during drills would otherwise freeze every withdrawal system-wide the moment the perimeter goes live. - InspectController drove the delay-bypass dump off the surface policy, so a soft-retired surface bypass (the documented disable-while-auditable state) printed as absent. It now consults the surface-bypass key index. Merge casualties restored (weakened when the fee line merged in): - upgrade test re-checks admin + perimeter state survive upgradeTo; non-owner upgrade pins the exact revert; overflow guard pins its exact boundary both ways; removeActorPolicies pins pre-state and stored-policy clearing; disabled-gate test regains its positive control. Deploy shape and comments: - 05 no longer forbids admin == owner: that is the launch shape (the governance Safe holds both roles), and the header NatSpec claiming a hard separation is corrected to match the contract. - Reviewer-finding ids stripped from comments and on-chain revert strings; '(Finding N)' pointers dropped, keeping the property each stated. Suite 420 -> 425. --- script/05_DeployQueueAndWire.s.sol | 11 ++- script/06_VerifyActivation.s.sol | 52 ++++++++------ script/InspectController.s.sol | 19 +++++- src/ExitDelayQueue.sol | 84 +++++++++++++++++------ src/ExitFeeController.sol | 4 +- src/interfaces/IExitFeeController.sol | 2 +- test/unit/DeployQueueAndWire.t.sol | 8 +-- test/unit/ExitDelayQueue.t.sol | 97 ++++++++++++++++++++++++++- test/unit/ExitFeeController.t.sol | 53 ++++++++++++++- test/unit/VerifyActivation.t.sol | 32 ++++----- 10 files changed, 286 insertions(+), 76 deletions(-) diff --git a/script/05_DeployQueueAndWire.s.sol b/script/05_DeployQueueAndWire.s.sol index 70f98d3..83d830c 100644 --- a/script/05_DeployQueueAndWire.s.sol +++ b/script/05_DeployQueueAndWire.s.sol @@ -31,7 +31,7 @@ import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; /// BEFORE the Owner has configured the controller's admin + global delay /// (steps 4–5), so `controller.admin() == 0` and `globalDelaySeconds() == 0` /// at this point. A correctly-ordered first deploy would therefore ALWAYS -/// revert if the assertions lived here (the SP2-CTRL-02-ordering bug). +/// revert if the assertions lived here (the ordering constraint below). /// Instead they live in the dedicated READ-ONLY `06_VerifyActivation.s.sol` /// verify script, run LAST as the step-7 go-live gate — AFTER the Owner /// has configured admin + globalDelaySeconds. @@ -200,10 +200,9 @@ contract DeployQueueAndWire is Script { "05: EXIT_DELAY_QUEUE_OWNER must be set (C1: zero owner => deployer EOA holds queue authority)" ); require(cfg.queueAdmin != address(0), "05: EXIT_DELAY_QUEUE_ADMIN must be set"); - require( - cfg.queueAdmin != cfg.queueOwner, - "05: EXIT_DELAY_QUEUE_ADMIN must differ from EXIT_DELAY_QUEUE_OWNER (Admin != Owner)" - ); + // No admin-vs-owner separation is enforced: the launch shape has the + // governance Safe holding both roles, and the authority split becomes + // meaningful only once ownership later moves while the admin stays put. require(cfg.wrbtc != address(0), "05: WRBTC_ADDRESS must be set"); // C2 — a blank host must ABORT (never no-op-wire a fail-open zero-delay @@ -219,7 +218,7 @@ contract DeployQueueAndWire is Script { ); } - // (GATE4-02 / SP2-G5R2-01) A non-zero duplicate pair (both spec-named host + // (C2 duplicate-host guard) A non-zero duplicate pair (both spec-named host // vars pointing at the SAME address — a copy-paste footgun) would wire one // surface twice and leave the OTHER silently unwired at zero-delay. Reject // it. The `== address(0)` clause preserves the both-zero DEFER_HOSTS path. diff --git a/script/06_VerifyActivation.s.sol b/script/06_VerifyActivation.s.sol index 63bd12d..7388629 100644 --- a/script/06_VerifyActivation.s.sol +++ b/script/06_VerifyActivation.s.sol @@ -54,7 +54,7 @@ import {IExitDelayQueueHost} from "../src/interfaces/IExitDelayQueueHost.sol"; /// /// The assertions live HERE — NOT inside `05_DeployQueueAndWire`'s broadcast /// (step 1, BEFORE config) — so the documented activation order does not -/// self-abort (the SP2-CTRL-02-ordering fix). +/// self-abort. /// /// Read-only: no `vm.startBroadcast()`, no state change. verify() does NOT /// mutate (roles-not-actors: setAdmin/setGlobalDelaySeconds stay Owner @@ -105,7 +105,7 @@ contract VerifyActivation is Script { verify(controller, queue, governanceOwner, deployer, hosts, deferHosts); - // (G5R2-02) The banner is keyed off the RESOLVED FACT — whether BOTH + // (C2) The banner is keyed off the RESOLVED FACT — whether BOTH // spec-named hosts were actually C2-checked — NOT the raw VERIFY_DEFER_HOSTS // flag: with defer=true but both hosts present, all surfaces ARE certified // and the unqualified banner is correct; the qualified/warned banner is for @@ -178,14 +178,14 @@ contract VerifyActivation is Script { view returns (address[] memory hosts) { - // (GATE4-02 / SP2-G5R2-01) A non-zero duplicate pair (both spec-named vars + // (C2 duplicate-host guard) A non-zero duplicate pair (both spec-named vars // pointing at the SAME host — a copy-paste footgun) would C2-check one // surface twice and leave the OTHER silently unchecked/unwired. Reject it. // The `== address(0)` clause preserves the both-zero EXPLICIT-defer path // (deferHosts==true, both hosts intentionally unset): that is not a dup. require( sovrynHost != zeroHost || sovrynHost == address(0), - "SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" + "C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)" ); bool sovrynIn = _includeHost(deferHosts, sovrynHost, "sovrynProtocol (SOVRYN_PROTOCOL_HOST)"); @@ -216,7 +216,7 @@ contract VerifyActivation is Script { require( deferHosts, string.concat( - "SP2-CTRL-02 (C1): intended host ", + "C1: intended host ", label, " == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" ) @@ -231,7 +231,7 @@ contract VerifyActivation is Script { /// (`anyHostDeferred == false`). When at least one spec-named host was /// ACTUALLY dropped from the checked list (a real deferral) the banner is /// DOWNGRADED to the qualified/warned variant so a deferred surface is never - /// implicitly certified. (G5R2-02: keyed off the resolved fact, NOT the raw + /// implicitly certified. (C2: keyed off the resolved fact, NOT the raw /// VERIFY_DEFER_HOSTS flag — defer=true with both hosts present certifies /// everything and earns the unqualified banner.) /// @param anyHostDeferred true iff fewer than the two spec-named hosts were @@ -314,14 +314,24 @@ contract VerifyActivation is Script { // opt-in may legitimately produce an empty list. require( hosts.length != 0 || deferHosts, - "SP2-CTRL-02 (C1): empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + "empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" ); - _verifyGuardian(controller, queue); // + _verifyGuardian(controller, queue); _verifyFloor(controller, queue); - // + _verifyNotPaused(queue); _verifyOwnership(controller, queue, governanceOwner, deployer); - // C1 - _verifyWiring(queue, hosts); // C2 + _verifyWiring(queue, hosts); + } + + // ── The queue must not be left paused. A paused queue keeps escrowing + // ingress but blocks every executeExit/recoverStuckExit, so enabling the + // perimeter against it freezes every withdrawal system-wide. A pause set + // during setup drills must be cleared before go-live. ── + function _verifyNotPaused(ExitDelayQueue queue) internal view { + require( + !queue.securityPerimeterPaused(), + "queue is paused -- unpause before enabling the perimeter (step 8)" + ); } // ── single guardian. "not yet configured" (admin==0) is distinct from a @@ -377,15 +387,15 @@ contract VerifyActivation is Script { ) internal view { require( governanceOwner != address(0), - "SP2-CTRL-02 (C1 unconfigured): governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + "C1: governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" ); require( deployer != address(0), - "SP2-CTRL-02 (C1 unconfigured): deployer arg == 0 -- set EXIT_DELAY_DEPLOYER" + "C1: deployer arg == 0 -- set EXIT_DELAY_DEPLOYER" ); require( governanceOwner != deployer, - "SP2-CTRL-02 (C1 unconfigured): governance owner == deployer -- they must differ" + "C1: governance owner == deployer -- they must differ" ); address queueOwner = queue.owner(); @@ -394,14 +404,14 @@ contract VerifyActivation is Script { // still-deployer first (the silent-blank-owner footgun this gate exists for) require( queueOwner != deployer, - "SP2-CTRL-02 (C1): queue.owner() == deployer EOA -- ownership not handed to governance" + "C1: queue.owner() == deployer EOA -- ownership not handed to governance" ); require( ctrlOwner != deployer, - "SP2-CTRL-02 (C1): controller.owner() == deployer EOA -- ownership not handed to governance" + "C1: controller.owner() == deployer EOA -- ownership not handed to governance" ); - require(queueOwner == governanceOwner, "SP2-CTRL-02 (C1): queue.owner() != governance owner"); - require(ctrlOwner == governanceOwner, "SP2-CTRL-02 (C1): controller.owner() != governance owner"); + require(queueOwner == governanceOwner, "C1: queue.owner() != governance owner"); + require(ctrlOwner == governanceOwner, "C1: controller.owner() != governance owner"); } // ── C2: wiring. Every intended host must (i) point its queue pointer at THIS @@ -415,12 +425,12 @@ contract VerifyActivation is Script { // (defensive: a zero host is never an intended host — _resolveHosts // filters/reverts them — but guard so a caller-supplied list cannot // slip a 0.) - require(host != address(0), "SP2-CTRL-02 (C2): intended host == 0"); + require(host != address(0), "C2: intended host == 0"); require( IExitDelayQueueHost(host).exitDelayQueue() == address(queue), string.concat( - "SP2-CTRL-02 (C2): host ", + "C2: host ", vm.toString(host), " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" ) @@ -428,7 +438,7 @@ contract VerifyActivation is Script { require( queue.isAllowedSource(host), string.concat( - "SP2-CTRL-02 (C2): host ", + "C2: host ", vm.toString(host), " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" ) diff --git a/script/InspectController.s.sol b/script/InspectController.s.sol index c484ff9..d5e9fb9 100644 --- a/script/InspectController.s.sol +++ b/script/InspectController.s.sol @@ -182,6 +182,17 @@ contract InspectController is Script { return vm.toString(id); } + /// @dev Is `id` in the surface-bypass key index? A soft-retired surface + /// entry ({false,false}) is indistinguishable from never-set by its + /// policy alone; the key set is the source of truth for presence. + function _surfaceBypassKeyPresent(ExitFeeController c, bytes32 id) internal view returns (bool) { + bytes32[] memory keys = c.surfaceBypassKeys(); + for (uint256 i = 0; i < keys.length; i++) { + if (keys[i] == id) return true; + } + return false; + } + /// @dev dump every surface / sub-product / actor delay-bypass /// entry, driven by the ANY-TIER-TOUCHED probe set (`bypassSurfaceIds()` ∪ /// `passthroughSurfaceIds()` ∪ named surfaces) so a sub-product- or @@ -197,10 +208,14 @@ contract InspectController is Script { IExitFeeController.DelayBypassPolicy memory sb = c.surfaceBypass(id); address[] memory subBp = c.subProductBypassKeys(id); address[] memory actorBp = c.actorBypassKeys(id); + // A surface-tier entry set then soft-retired reads {false,false} but + // still lives in the key index; it must stay visible, since the + // documented way to disable-while-auditable is exactly that state. + bool surfaceEntryPresent = _surfaceBypassKeyPresent(c, id); // Skip a probed id that carries no bypass entry at any tier (e.g. a - // named fee surface or a passthrough-only surface with no bypass). - if (!sb.active && !sb.bypass && subBp.length == 0 && actorBp.length == 0) { + // named fee surface or a passthrough-only surface never given one). + if (!surfaceEntryPresent && subBp.length == 0 && actorBp.length == 0) { continue; } anyPrinted = true; diff --git a/src/ExitDelayQueue.sol b/src/ExitDelayQueue.sol index 378290e..bdf051a 100644 --- a/src/ExitDelayQueue.sol +++ b/src/ExitDelayQueue.sol @@ -34,8 +34,9 @@ interface IWRBTC { /// Two-principal authority: `Owner` (Ownable2Step) holds UUPS /// upgrade + all security-critical CONFIG; `Admin` (a single stored /// address, `onlyAdminOrOwner`) is the fast guardian — freeze/blacklist, -/// pause, Leg-1 release, Leg-2 along Owner-approved routes. `Admin ≠ -/// Owner` is the one hard separation. +/// pause, Leg-1 release, Leg-2 along Owner-approved routes. The two roles +/// MAY be the same address; the authority split constrains the Admin only +/// once ownership moves to a separate holder. // // The queue custodies escrowed RBTC/ERC20/WRBTC by design; the only value-in // path is the gated ingress (record*/receive()), and value-out is CEI-ordered @@ -100,9 +101,9 @@ contract ExitDelayQueue is // (restoring the pre-marker layout, still 32). /// @notice Fast operational guardian. Not an OZ AccessControl role — - /// a single stored address checked by `onlyAdminOrOwner`. MUST be - /// distinct from the Owner for the authority bounds to - /// hold; enforced at `initialize` and `setAdmin`. + /// a single stored address checked by `onlyAdminOrOwner`. MAY equal + /// the Owner; the authority bounds between the two roles bind only + /// once ownership moves to a separate holder. address public admin; /// @notice Monotonic id source; ids are never reused. The first @@ -187,6 +188,8 @@ contract ExitDelayQueue is error NotAdminOrOwner(address caller); error OwnershipCannotBeRenounced(); error UpgradeImplZero(); + error InsufficientGasForRecovery(); + error InvalidDestination(address destination); // ─── Construction / initialization ────────────────────────────────── @@ -566,14 +569,34 @@ contract ExitDelayQueue is emit ExitExecuted(id, paid, token, amount); } + /// @notice Gas budget the stored-receiver payout attempt is given, and the + /// floor the caller must leave beyond it. A genuine receiver revert + /// returns control with the floor intact; only a receiver that needs + /// MORE than the budget is read as a bounce. Fixing the budget and + /// requiring the frame to hold it means the fall-through to + /// `altReceiver` cannot be reached by starving the attempt: a caller + /// who supplies too little gas reverts the whole call instead of + /// redirecting a healthy exit. The budget covers any realistic ERC20 + /// transfer or WRBTC unwrap-and-send with wide margin. + uint256 public constant RECOVER_PAYOUT_GAS = 3_000_000; + uint256 internal constant RECOVER_GAS_FLOOR = 200_000; + /// @dev Catchable single-payout attempt for `recoverStuckExit`. Routes the /// transfer through an EXTERNAL self-call so a reverting recipient is /// caught (Solidity cannot catch a low-level revert inline) and the leg - /// can fall through to `altReceiver`. Returns false on any failure. + /// can fall through to `altReceiver`. Returns false on a genuine bounce. /// Self-only (`msg.sender == address(this)`); NOT `nonReentrant` — it runs /// inside `recoverStuckExit`'s guard, and CEI already made the state safe. + /// + /// The explicit gas cap plus the pre-check are the guard against a false + /// bounce: without a fixed budget, EIP-150's 63/64 rule lets a caller + /// pick a gas limit that out-of-gases the attempt (caught here as a + /// "bounce") while the retained 1/64 still completes the `altReceiver` + /// payout — redirecting a healthy exit. Reserving the budget and + /// reverting when the frame cannot cover it closes that path. function _tryPayout(address token, address to, uint128 amount, bool unwrap) internal returns (bool) { - try this.payoutExternal(token, to, amount, unwrap) { + if (gasleft() < RECOVER_PAYOUT_GAS + RECOVER_GAS_FLOOR) revert InsufficientGasForRecovery(); + try this.payoutExternal{gas: RECOVER_PAYOUT_GAS}(token, to, amount, unwrap) { return true; } catch { return false; @@ -716,9 +739,14 @@ contract ExitDelayQueue is // that still refreshes trigger/reason (does not downgrade). BlockState from = _blockState[a]; if (to == BlockState.Frozen && from == BlockState.Blacklisted) { - // hold the stronger state; refresh evidence only - _blockTrigger[a] = triggerId; - emit AccountBlocked(a, from, triggerId, reasonHash); + // Hold the stronger state. A freeze carrying no evidence (the plain + // `freeze(address)` passes triggerId 0 / reason 0) must not erase the + // blacklist's recorded trigger and reason; only a by-request freeze + // that carries real evidence refreshes them. + if (triggerId != 0 || reasonHash != bytes32(0)) { + _blockTrigger[a] = triggerId; + emit AccountBlocked(a, from, triggerId, reasonHash); + } return; } if (from == BlockState.None) { @@ -806,28 +834,37 @@ contract ExitDelayQueue is } /// @inheritdoc IExitDelayQueue - /// @dev Leg-3: Owner catch-all, bounded to a blocked/held/non-executable - /// request — the DAO can never touch an honest, fully-unblocked, - /// unlocked, in-flight exit. + /// @dev Leg-3: Owner catch-all, bounded to a request that is blocked, paused, + /// or still inside its delay window. A request past its unlock time with + /// no party blocked and the queue unpaused is out of reach here — the + /// delay window is deliberately in scope, because holding a withdrawal + /// until it unlocks so a detected theft can be resolved away is the whole + /// point of the queue. function resolveBySIP(uint256[] calldata ids, address destination) external nonReentrant onlyOwner { if (ids.length == 0) revert EmptyIds(); - if (destination == address(0)) revert ZeroAddress(); + // Same destination guard as recoverStuckExit's altReceiver: never the + // zero address, this contract (would trap the escrow), or WRBTC (a + // wrapped-token destination silently swallows an unwrap payout). The + // per-request `destination == token` case is rejected inside the loop. + if (destination == address(0) || destination == address(this) || destination == wrbtc) { + revert InvalidDestination(destination); + } for (uint256 i = 0; i < ids.length; ++i) { uint256 id = ids[i]; ExitRequest storage r = _requests[id]; if (r.status == ExitStatus.None) revert UnknownRequest(id); if (r.status != ExitStatus.Queued) revert AlreadyTerminal(id); - // Bounded predicate: blocked | paused | locked. The DAO can never - // touch an honest, fully-unblocked, unlocked, unpaused in-flight exit. - // A bouncing (but unblocked) recipient is NOT admitted here — it is - // handled self-service via recoverStuckExit(id, altReceiver), - // so there is no _payoutFailed term (that mechanism was removed). + // Bounded predicate: blocked | paused | locked. A fully-unblocked, + // unlocked, unpaused request is out of reach. A bouncing (but + // unblocked) recipient is NOT admitted here — it is handled + // self-service via recoverStuckExit(id, altReceiver). bool resolvable = _isBlocked(r.originator) || _isBlocked(r.owner) || _isBlocked(r.receiver) || securityPerimeterPaused || block.timestamp < r.unlockAt; if (!resolvable) revert NotResolvableBySIP(id); address token = r.token; + if (destination == token) revert InvalidDestination(destination); uint128 amount = r.amount; r.status = ExitStatus.ResolvedBySIP; _removeActive(id, r.originator, r.owner); @@ -845,7 +882,14 @@ contract ExitDelayQueue is /// @inheritdoc IExitDelayQueue function setRecoveryRoute(RecoveryRoute calldata route) external onlyOwner returns (bytes32 routeId) { - if (route.destination == address(0)) revert ZeroAddress(); + // Destination guard, same as the resolve legs: never the zero address, + // this contract (would trap escrow), the escrowed token, or WRBTC (a + // wrapped-token destination swallows an unwrap payout). A topUpPool route + // is pinned tighter still, to route.subProduct, below. + if ( + route.destination == address(0) || route.destination == address(this) + || route.destination == route.token || route.destination == wrbtc + ) revert InvalidDestination(route.destination); // topUpPool routes restricted on-chain to feasible surfaces and // to non-native tokens (a native request can never be Leg-2a). if (route.topUpPool) { diff --git a/src/ExitFeeController.sol b/src/ExitFeeController.sol index 05a852f..100813c 100644 --- a/src/ExitFeeController.sol +++ b/src/ExitFeeController.sol @@ -204,7 +204,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable /// so margin/Zero (no entry) keep `effOrig = raw`, `effOwner = owner`. /// Co-located here (not in the escrow queue) so the hook normalizes /// WITHOUT touching the queue, keeping the kill switch queue-independent - /// (Finding 3). + /// . mapping(bytes32 => mapping(address => bool)) internal _passthroughActor; /// @dev Enumeration index for the surface-scoped passthrough registry. @@ -1068,7 +1068,7 @@ contract ExitFeeController is IExitFeeController, Initializable, UUPSUpgradeable } // Resolve the surface-scoped effective identities, then quote on - // effOrig, so the quote and the record share ONE identity (Finding 2). + // effOrig, so the quote and the record share ONE identity. effOrig = effectiveActor(surfaceId, rawOriginator, receiver); effOwner = effectiveActor(surfaceId, owner_, receiver); d = _resolveDelay(surfaceId, subProduct, effOrig); diff --git a/src/interfaces/IExitFeeController.sol b/src/interfaces/IExitFeeController.sol index 40c864c..ee29110 100644 --- a/src/interfaces/IExitFeeController.sol +++ b/src/interfaces/IExitFeeController.sol @@ -122,7 +122,7 @@ interface IExitFeeController { /// (`effOrig`/`effOwner`) — a registered passthrough for `surfaceId` /// resolves to `receiver` — quotes the delay on `effOrig`, and /// returns all three so the quote and the record share ONE identity - /// (Finding 2). The hook MUST ignore `effOrig`/`effOwner` and pay + /// . The hook MUST ignore `effOrig`/`effOwner` and pay /// direct whenever `d == 0`. /// @param rawOriginator The withdrawal caller (pre-normalization). /// @param owner The position owner (iToken holder / borrower / trove). diff --git a/test/unit/DeployQueueAndWire.t.sol b/test/unit/DeployQueueAndWire.t.sol index 0604660..d3bdf1d 100644 --- a/test/unit/DeployQueueAndWire.t.sol +++ b/test/unit/DeployQueueAndWire.t.sol @@ -164,13 +164,11 @@ contract DeployQueueAndWireRunTest is Test { script.validateConfig(cfg, false); } - // ── (C1) Admin == Owner ABORTS (Admin != Owner separation). ── - function test_validateConfig_reverts_when_admin_equals_owner() public { + // ── Admin == Owner is the supported launch shape and must validate. ── + function test_validateConfig_accepts_admin_equal_to_owner() public view { DeployQueueAndWire.DeployConfig memory cfg = _validCfg(); cfg.queueAdmin = cfg.queueOwner; - vm.expectRevert( - bytes("05: EXIT_DELAY_QUEUE_ADMIN must differ from EXIT_DELAY_QUEUE_OWNER (Admin != Owner)") - ); + // Does not revert: the governance Safe holds both roles at launch. script.validateConfig(cfg, false); } diff --git a/test/unit/ExitDelayQueue.t.sol b/test/unit/ExitDelayQueue.t.sol index 7b66f0d..75a0166 100644 --- a/test/unit/ExitDelayQueue.t.sol +++ b/test/unit/ExitDelayQueue.t.sol @@ -190,6 +190,23 @@ contract RevertingReceiver { } } +/// @dev Receiver that burns gas in an unbounded loop until it runs out. Used to +/// show a payout attempt that consumes more than the recovery gas budget is +/// read as a bounce, while a caller who under-funds the whole call reverts +/// instead of redirecting. +contract GasSinkReceiver { + receive() external payable { + uint256 i; + while (true) { + i += 1; + // touch storage-ish work to consume gas fast + assembly { + mstore(0x0, i) + } + } + } +} + /// @dev ERC20 whose transferFrom re-enters the queue's ingress. Proves /// the `nonReentrant` guard on the four record* fns rejects a re-entrant /// record during the token pull. The reentrant call MUST revert with the @@ -1130,6 +1147,82 @@ contract ExitDelayQueueTest is Test { assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); } + /// @notice A caller cannot force a false bounce by starving the stored-receiver + /// payout: supplying less than the recovery gas budget reverts the whole + /// call (InsufficientGasForRecovery) rather than redirecting to altReceiver. + function test_recover_reverts_when_gas_below_budget() public { + GasSinkReceiver sink = new GasSinkReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(sink) + ); + vm.warp(block.timestamp + DELAY); + + // Give the call less than RECOVER_PAYOUT_GAS + floor: it must revert, + // not swallow an out-of-gas as a bounce and pay ALT. + vm.prank(OWNR); + vm.expectRevert(ExitDelayQueue.InsufficientGasForRecovery.selector); + queue.recoverStuckExit{gas: 2_000_000}(id, ALT); + + // Escrow untouched, request still Queued — no redirect happened. + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Queued)); + assertEq(queue.totalEscrowed(address(0)), 4 ether); + } + + /// @notice A receiver that genuinely needs more than the gas budget IS a + /// bounce: with ample total gas, the stored-receiver attempt is capped, + /// caught, and altReceiver is paid — the intended stuck-exit recovery. + function test_recover_gas_sink_receiver_falls_through_to_alt() public { + GasSinkReceiver sink = new GasSinkReceiver(); + uint256 id = source.recordNative{value: 4 ether}( + 4 ether, DELAY, SURFACE_ZERO, address(0), ORIG, OWNR, address(sink) + ); + vm.warp(block.timestamp + DELAY); + + uint256 altBefore = ALT.balance; + vm.prank(OWNR); + queue.recoverStuckExit(id, ALT); // ample gas from the test harness + assertEq(ALT.balance, altBefore + 4 ether, "alt paid when receiver exceeds budget"); + assertEq(uint256(queue.getRequest(id).status), uint256(IExitDelayQueue.ExitStatus.Executed)); + } + + /// @notice resolveBySIP rejects a destination that would trap or swallow the + /// escrow: the queue itself, WRBTC, or the escrowed token. + function test_resolveBySIP_rejects_trapping_destinations() public { + uint256 id = _queueErc20(10 ether); + vm.prank(ADMIN); + queue.setSecurityPerimeterPaused(true); // make the request resolvable + uint256[] memory ids = new uint256[](1); + ids[0] = id; + + vm.startPrank(OWNER); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(queue))); + queue.resolveBySIP(ids, address(queue)); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(wrbtc))); + queue.resolveBySIP(ids, address(wrbtc)); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(token))); + queue.resolveBySIP(ids, address(token)); + vm.stopPrank(); + } + + /// @notice A plain freeze on an already-blacklisted address holds the stronger + /// state AND preserves the blacklist's recorded trigger — a no-evidence + /// freeze must not erase why the address was blacklisted. + function test_freeze_on_blacklisted_preserves_trigger() public { + uint256 id = _queueErc20(1 ether); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.startPrank(ADMIN); + queue.blacklistFromRequest(ids, false, keccak256("theft")); + uint256 triggerBefore = queue.blockTrigger(ORIG); + assertEq(triggerBefore, id, "blacklist recorded the request id"); + + // Plain freeze carries no evidence (trigger 0): must not overwrite. + queue.freeze(ORIG); + vm.stopPrank(); + assertEq(uint256(queue.blockStateOf(ORIG)), uint256(IExitDelayQueue.BlockState.Blacklisted)); + assertEq(queue.blockTrigger(ORIG), triggerBefore, "trigger preserved through freeze"); + } + /// @notice (b) HEALTHY original receiver → recoverStuckExit pays the STORED /// receiver; altReceiver is IGNORED (proves no arbitrary redirect — /// a healthy exit can never be diverted, verify-by-attempting). @@ -2062,7 +2155,7 @@ contract ExitDelayQueueTest is Test { uint256[] memory ids = new uint256[](1); ids[0] = 1; vm.prank(OWNER); - vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(0))); queue.resolveBySIP(ids, address(0)); } @@ -2194,7 +2287,7 @@ contract ExitDelayQueueTest is Test { topUpPool: false }); vm.prank(OWNER); - vm.expectRevert(IExitDelayQueue.ZeroAddress.selector); + vm.expectRevert(abi.encodeWithSelector(ExitDelayQueue.InvalidDestination.selector, address(0))); queue.setRecoveryRoute(route); } diff --git a/test/unit/ExitFeeController.t.sol b/test/unit/ExitFeeController.t.sol index 50031a7..fe6d56e 100644 --- a/test/unit/ExitFeeController.t.sol +++ b/test/unit/ExitFeeController.t.sol @@ -125,6 +125,16 @@ contract ExitFeeControllerTest is Test { assertEq(q.feeAmount, 0); assertEq(q.netAmount, 1_000_000); assertEq(q.reason, uint8(IExitFeeController.SkipReason.DISABLED)); + + // Positive control: flip only the missing gate on and the suppressed + // override must revive. Without this, the test would pass just as + // happily against a controller whose fee path was dead altogether. + vm.prank(ADMIN); + controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 20})); + q = _quote(IWRBTC, 1_000_000); + assertTrue(q.active); + assertEq(q.rateBps, 5); + assertEq(q.reason, uint8(IExitFeeController.SkipReason.NONE)); } // ─── Tier resolution: surface → subProduct → actor ────────────────── @@ -296,6 +306,30 @@ contract ExitFeeControllerTest is Test { assertEq(q.netAmount, huge); // synthesized echo of gross } + /// @dev Pins the guard's exact boundary in both directions: the largest + /// gross that cannot overflow quotes honestly, one wei more trips + /// INVALID_QUOTE. A one-sided test would pass with the comparison + /// direction flipped. + function test_overflow_guard_exact_boundary() public { + vm.startPrank(ADMIN); + controller.setFeeReceiver(VAULT); + controller.setExitFeeEnabled(true); + controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 50})); + vm.stopPrank(); + + uint256 edge = type(uint256).max / 10_000; + + IExitFeeController.ExitFeeQuote memory qEdge = _quote(IXUSD, edge); + assertTrue(qEdge.active, "edge value must quote honestly"); + assertEq(qEdge.feeAmount, (edge * 50) / 10_000); + assertEq(qEdge.netAmount, edge - qEdge.feeAmount); + + IExitFeeController.ExitFeeQuote memory qOver = _quote(IXUSD, edge + 1); + assertFalse(qOver.active); + assertEq(qOver.reason, uint8(IExitFeeController.SkipReason.INVALID_QUOTE)); + assertEq(qOver.netAmount, edge + 1); + } + // ─── Admin / setter validation ─────────────────────────────────────── function test_setRate_above_max_reverts() public { @@ -560,11 +594,20 @@ contract ExitFeeControllerTest is Test { ps[1] = IExitFeeController.RatePolicy({active: true, rateBps: 10}); controller.setActorPolicies(SURFACE, actors, ps); + // Pre-state pin: without this, the length-0 assertion below would + // also hold if the batch SET had never populated the index at all. + assertEq(controller.actorKeys(SURFACE).length, 2); + // Removing the same list back drops both keys. controller.removeActorPolicies(SURFACE, actors); vm.stopPrank(); assertEq(controller.actorKeys(SURFACE).length, 0); + // Hard-remove clears the stored policy itself, not only the index -- + // a resurrected key must not revive an old rate. + IExitFeeController.RatePolicy memory cleared = controller.actorPolicy(SURFACE, ACTOR); + assertFalse(cleared.active); + assertEq(cleared.rateBps, 0); } function test_remove_address_zero_reverts() public { @@ -618,6 +661,11 @@ contract ExitFeeControllerTest is Test { controller.setFeeReceiver(VAULT); controller.setExitFeeEnabled(true); controller.setSurfacePolicy(SURFACE, IExitFeeController.RatePolicy({active: true, rateBps: 25})); + // The post-256 slots are the ones an upgrade regression would hit + // first: admin alone in its slot, then the packed perimeter pair. + controller.setAdmin(GUARDIAN); + controller.setSecurityPerimeterEnabled(true); + controller.setGlobalDelaySeconds(7 days); vm.stopPrank(); ExitFeeControllerV2Mock v2impl = new ExitFeeControllerV2Mock(); @@ -635,6 +683,9 @@ contract ExitFeeControllerTest is Test { IExitFeeController.RatePolicy memory sp = controller.surfacePolicy(SURFACE); assertTrue(sp.active); assertEq(sp.rateBps, 25); + assertEq(controller.admin(), GUARDIAN); + assertTrue(controller.securityPerimeterEnabled()); + assertEq(controller.globalDelaySeconds(), 7 days); // 3) Quote still works post-upgrade with the preserved policy. IExitFeeController.ExitFeeQuote memory q = _quote(IXUSD, 1_000_000); @@ -646,7 +697,7 @@ contract ExitFeeControllerTest is Test { function test_non_owner_cannot_upgrade() public { ExitFeeControllerV2Mock v2impl = new ExitFeeControllerV2Mock(); vm.prank(OTHER); - vm.expectRevert(); // Ownable: caller is not the owner + vm.expectRevert("Ownable: caller is not the owner"); controller.upgradeTo(address(v2impl)); } diff --git a/test/unit/VerifyActivation.t.sol b/test/unit/VerifyActivation.t.sol index cbe6e8b..b9564d5 100644 --- a/test/unit/VerifyActivation.t.sol +++ b/test/unit/VerifyActivation.t.sol @@ -213,7 +213,7 @@ contract VerifyActivationTest is Test { queue.acceptOwnership(); vm.expectRevert( - bytes("SP2-CTRL-02 (C1): queue.owner() == deployer EOA -- ownership not handed to governance") + bytes("C1: queue.owner() == deployer EOA -- ownership not handed to governance") ); _verify(); } @@ -234,7 +234,7 @@ contract VerifyActivationTest is Test { vm.expectRevert( bytes( - "SP2-CTRL-02 (C1): controller.owner() == deployer EOA -- ownership not handed to governance" + "C1: controller.owner() == deployer EOA -- ownership not handed to governance" ) ); _verify(); @@ -256,7 +256,7 @@ contract VerifyActivationTest is Test { vm.prank(strayOwner); queue.acceptOwnership(); - vm.expectRevert(bytes("SP2-CTRL-02 (C1): queue.owner() != governance owner")); + vm.expectRevert(bytes("C1: queue.owner() != governance owner")); _verify(); } @@ -274,7 +274,7 @@ contract VerifyActivationTest is Test { vm.prank(strayOwner); controller.acceptOwnership(); - vm.expectRevert(bytes("SP2-CTRL-02 (C1): controller.owner() != governance owner")); + vm.expectRevert(bytes("C1: controller.owner() != governance owner")); _verify(); } @@ -283,7 +283,7 @@ contract VerifyActivationTest is Test { _makeFullyCorrect(); vm.expectRevert( bytes( - "SP2-CTRL-02 (C1 unconfigured): governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" + "C1: governance owner arg == 0 -- set EXIT_DELAY_GOVERNANCE_OWNER" ) ); script.verify(controller, queue, address(0), DEPLOYER, _hosts(), false); @@ -291,14 +291,14 @@ contract VerifyActivationTest is Test { function test_verify_reverts_when_deployer_arg_zero() public { _makeFullyCorrect(); - vm.expectRevert(bytes("SP2-CTRL-02 (C1 unconfigured): deployer arg == 0 -- set EXIT_DELAY_DEPLOYER")); + vm.expectRevert(bytes("C1: deployer arg == 0 -- set EXIT_DELAY_DEPLOYER")); script.verify(controller, queue, GOV_OWNER, address(0), _hosts(), false); } function test_verify_reverts_when_governance_owner_equals_deployer() public { _makeFullyCorrect(); vm.expectRevert( - bytes("SP2-CTRL-02 (C1 unconfigured): governance owner == deployer -- they must differ") + bytes("C1: governance owner == deployer -- they must differ") ); script.verify(controller, queue, GOV_OWNER, GOV_OWNER, _hosts(), false); } @@ -313,7 +313,7 @@ contract VerifyActivationTest is Test { vm.expectRevert( bytes( string.concat( - "SP2-CTRL-02 (C2): host ", + "C2: host ", vm.toString(address(host)), " not wired -- host.exitDelayQueue() != queue (fail-open zero-delay)" ) @@ -336,7 +336,7 @@ contract VerifyActivationTest is Test { vm.expectRevert( bytes( string.concat( - "SP2-CTRL-02 (C2): host ", + "C2: host ", vm.toString(address(unregHost)), " not allowed-source -- queue.isAllowedSource(host)==false (bricked fail-closed)" ) @@ -350,7 +350,7 @@ contract VerifyActivationTest is Test { _makeFullyCorrect(); address[] memory hs = new address[](1); hs[0] = address(0); - vm.expectRevert(bytes("SP2-CTRL-02 (C2): intended host == 0")); + vm.expectRevert(bytes("C2: intended host == 0")); script.verify(controller, queue, GOV_OWNER, DEPLOYER, hs, false); } @@ -377,7 +377,7 @@ contract VerifyActivationTest is Test { function test_resolveHosts_reverts_on_zero_sovryn_without_defer() public { vm.expectRevert( bytes( - "SP2-CTRL-02 (C1): intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + "C1: intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" ) ); script.resolveHosts(false, address(0), ZERO_HOST); @@ -387,7 +387,7 @@ contract VerifyActivationTest is Test { function test_resolveHosts_reverts_on_zero_zerohost_without_defer() public { vm.expectRevert( bytes( - "SP2-CTRL-02 (C1): intended host Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + "C1: intended host Zero BorrowerOperations (ZERO_BORROWER_OPERATIONS_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" ) ); script.resolveHosts(false, SOVRYN_HOST, address(0)); @@ -399,7 +399,7 @@ contract VerifyActivationTest is Test { function test_resolveHosts_reverts_on_both_zero_without_defer() public { vm.expectRevert( bytes( - "SP2-CTRL-02 (C1): intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" + "C1: intended host sovrynProtocol (SOVRYN_PROTOCOL_HOST) == 0 -- set it, or set VERIFY_DEFER_HOSTS=true to defer explicitly" ) ); script.resolveHosts(false, address(0), address(0)); @@ -433,7 +433,7 @@ contract VerifyActivationTest is Test { /// deferHosts (the check runs BEFORE the include/defer logic). function test_resolveHosts_reverts_on_duplicate_nonzero_hosts() public { vm.expectRevert( - bytes("SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + bytes("C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") ); script.resolveHosts(false, SOVRYN_HOST, SOVRYN_HOST); } @@ -443,7 +443,7 @@ contract VerifyActivationTest is Test { /// copy-paste of the same real address into both slots. function test_resolveHosts_reverts_on_duplicate_nonzero_hosts_even_when_deferred() public { vm.expectRevert( - bytes("SP2-CTRL-02 (C1): SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") + bytes("C1: SOVRYN_PROTOCOL_HOST == ZERO_BORROWER_OPERATIONS_HOST (duplicate host)") ); script.resolveHosts(true, ZERO_HOST, ZERO_HOST); } @@ -470,7 +470,7 @@ contract VerifyActivationTest is Test { address[] memory empty = new address[](0); vm.expectRevert( bytes( - "SP2-CTRL-02 (C1): empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" + "empty intended-host list without VERIFY_DEFER_HOSTS=true -- refusing vacuous wiring PASS" ) ); script.verify(controller, queue, GOV_OWNER, DEPLOYER, empty, false); From 793415dc8db88935a50dc49d8980fec36fac5e61 Mon Sep 17 00:00:00 2001 From: Tyrone Johnson Date: Sat, 29 Aug 2026 03:17:55 +0300 Subject: [PATCH 12/12] Adopt the fee branch as authoritative for naming, addresses and deployments Squashed adoption of sovryn-perimeter-fee into the delay line: bring in the authoritative Phase-1 deployment records on RSK mainnet, the deployment env template, and the proxy full-match verification helper. Per the decision to complete Phase 2 (delay) before Phase 1's SIPs land. Core suite 425 passing. --- .env.example | 54 +++ .../30/run-1787574212713.json | 164 +++++++ .../01_DeployVault.s.sol/30/run-latest.json | 118 ++--- .../30/run-1787576893913.json | 170 +++++++ .../30/run-latest.json | 106 ++--- .../30/run-1787577343971.json | 164 +++++++ .../30/run-latest.json | 116 ++--- .../30/run-1787578025992.json | 434 ++++++++++++++++++ .../30/run-latest.json | 316 ++++++------- deployments/30/ExitFeeController.json | 94 ++-- deployments/30/ExitFeeVault.json | 42 +- tools/verify-proxy-fullmatch.sh | 35 ++ 12 files changed, 1417 insertions(+), 396 deletions(-) create mode 100644 .env.example create mode 100644 broadcast/01_DeployVault.s.sol/30/run-1787574212713.json create mode 100644 broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json create mode 100644 broadcast/03_DeployController.s.sol/30/run-1787577343971.json create mode 100644 broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json create mode 100755 tools/verify-proxy-fullmatch.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9ecbda --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# Sovryn Perimeter Fee — deployment inputs for scripts 01–04. +# +# cp .env.example .env && $EDITOR .env && source .env +# +# `.env` is gitignored; this template is not. It holds no secrets: the signing +# key stays in the Foundry keystore and is selected with `--account `. +# +# Every variable below is REQUIRED. The scripts read them with vm.envAddress / +# vm.envUint / vm.envBool, which revert on an absent variable — there are no +# silent defaults, so a missing input stops the deploy instead of guessing. + +# --- RPC ------------------------------------------------------------------- +# foundry.toml exposes rsk_mainnet as "${RSK_MAINNET_RPC}"; the hardhat repos +# configure the same endpoint for rskSovrynMainnet. Pass it explicitly on every +# command (--rpc-url $RSK_RPC) rather than relying on a tool default. +export RSK_RPC=https://mainnet-dev.sovryn.app/rpc +export RSK_MAINNET_RPC=$RSK_RPC +# If a run stalls: https://mainnet.sovryn.app is the proven fallback. A stall is +# not a failed deploy — check the chain before doing anything, and never re-run +# a deploy script blind. + +# Broadcasting waits longer than the default; an RPC hiccup otherwise looks like +# a dropped transaction when the transaction actually landed. +export ETH_TIMEOUT=300 + +# --- Ownership and administration ------------------------------------------ +# All four are the Exchequer Multisig at launch. The vault and controller are +# deployed owned by the deployer, then handed over via Ownable2Step: the +# bootstrap scripts queue the transfer and the multisig accepts it separately. +export EXIT_FEE_VAULT_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_VAULT_RECIPIENT=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_CONTROLLER_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 +export EXIT_FEE_OPERATIONAL_ADMIN=0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711 + +# --- Launch rates, in basis points ----------------------------------------- +# 10 bps = 0.10% on each charging surface. The AMM surface is written inactive +# by the bootstrap and takes no rate: nothing consumes it in this release. +export PERIMETER_LENDING_LENDER_BPS=10 +export PERIMETER_LENDING_BORROWER_BPS=10 +export PERIMETER_ZERO_WITHDRAW_COLL_BPS=10 +export PERIMETER_ZERO_CLAIM_SURPLUS_BPS=10 + +# --- Charging switch -------------------------------------------------------- +# The release invariant: deploy disabled. Charging is enabled only after +# governance executes and every checkpoint is green. Setting this true at +# deploy time would charge users before the vote. +export PERIMETER_ENABLE_AT_DEPLOY=false + +# --- Filled in mid-deploy --------------------------------------------------- +# Not knowable up front: script 01 deploys the vault, and script 04 needs its +# proxy address to wire the controller's fee receiver. Export it after 01 and +# before 04 — script 04 reverts without it rather than bootstrapping a +# controller that could never pay a fee anywhere. +# export EXIT_FEE_VAULT_PROXY=0x... diff --git a/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json b/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json new file mode 100644 index 0000000..929c83b --- /dev/null +++ b/broadcast/01_DeployVault.s.sol/30/run-1787574212713.json @@ -0,0 +1,164 @@ +{ + "transactions": [ + { + "hash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionType": "CREATE", + "contractName": "ExitFeeVault", + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "function": null, + "arguments": null, + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x1c1077", + "value": "0x0", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b608051611829610118600039600081816103350152818161037e0152818161041d0152818161045d015261061a01526118296000f3fe6080604052600436106101025760003560e01c8063715018a611610095578063c4d66de811610064578063c4d66de81461028c578063e30c3978146102ac578063e6d11999146102ca578063f2fde38b146102ea578063f851a4401461030a57600080fd5b8063715018a61461022457806379ba5097146102395780637b1307cd1461024e5780638da5cb5b1461026e57600080fd5b8063503690d1116100d1578063503690d1146101a157806352d1902d146101c1578063557f1473146101e4578063704b6c021461020457600080fd5b80630b5e8df91461010e5780633659cfe61461014c5780634f1ef2861461016e5780634f7a6c3c1461018157600080fd5b3661010957005b600080fd5b34801561011a57600080fd5b5061012d5461012f906001600160a01b031681565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561015857600080fd5b5061016c61016736600461149e565b61032b565b005b61016c61017c3660046114d1565b610413565b34801561018d57600080fd5b5061016c61019c36600461149e565b6104e3565b3480156101ad57600080fd5b5061016c6101bc366004611595565b6105a4565b3480156101cd57600080fd5b506101d661060d565b604051908152602001610143565b3480156101f057600080fd5b5061016c6101ff3660046115d6565b6106c0565b34801561021057600080fd5b5061016c61021f36600461149e565b61074f565b34801561023057600080fd5b5061016c6107c9565b34801561024557600080fd5b5061016c6107e2565b34801561025a57600080fd5b5061016c6102693660046115ef565b610859565b34801561027a57600080fd5b506097546001600160a01b031661012f565b34801561029857600080fd5b5061016c6102a736600461149e565b6108bc565b3480156102b857600080fd5b5060c9546001600160a01b031661012f565b3480156102d657600080fd5b5061016c6102e53660046115ef565b610a14565b3480156102f657600080fd5b5061016c61030536600461149e565b610aa4565b34801561031657600080fd5b5061012e5461012f906001600160a01b031681565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361037c5760405162461bcd60e51b81526004016103739061161b565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166103c56000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146103eb5760405162461bcd60e51b815260040161037390611667565b6103f481610b15565b6040805160008082526020820190925261041091839190610b44565b50565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361045b5760405162461bcd60e51b81526004016103739061161b565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166104a46000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146104ca5760405162461bcd60e51b815260040161037390611667565b6104d382610b15565b6104df82826001610b44565b5050565b61012e546001600160a01b0316331480159061050a57506097546001600160a01b03163314155b1561052a5760405163fade6b7760e01b8152336004820152602401610373565b6001600160a01b03811661055157604051630acd20ff60e21b815260040160405180910390fd5b61012d80546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf90600090a35050565b6105ac610caf565b61012e546001600160a01b031633148015906105d357506097546001600160a01b03163314155b156105f35760405163fade6b7760e01b8152336004820152602401610373565b6105fe838383610d08565b610608600160fb55565b505050565b6000306001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146106ad5760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c00000000000000006064820152608401610373565b506000805160206117ad83398151915290565b6106c8610caf565b61012e546001600160a01b031633148015906106ef57506097546001600160a01b03163314155b1561070f5760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b03168061073a5760405163d3e94d1160e01b815260040160405180910390fd5b6107448183610d9c565b50610410600160fb55565b610757610e7f565b6001600160a01b03811661077e5760405163b325f76760e01b815260040160405180910390fd5b61012e80546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040516317d5c96560e11b815260040160405180910390fd5b60c95433906001600160a01b031681146108505760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b6064820152608401610373565b61041081610edb565b610861610caf565b61012e546001600160a01b0316331480159061088857506097546001600160a01b03163314155b156108a85760405163fade6b7760e01b8152336004820152602401610373565b6108b28282610d9c565b6104df600160fb55565b600054610100900460ff16158080156108dc5750600054600160ff909116105b806108f65750303b1580156108f6575060005460ff166001145b6109595760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b6064820152608401610373565b6000805460ff19166001179055801561097c576000805461ff0019166101001790555b610984610ef4565b61098c610ef4565b610994610f23565b61099c610f52565b6001600160a01b038216158015906109bd57506001600160a01b0382163314155b156109cb576109cb82610edb565b80156104df576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610a1c610caf565b61012e546001600160a01b03163314801590610a4357506097546001600160a01b03163314155b15610a635760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b031680610a8e5760405163d3e94d1160e01b815260040160405180910390fd5b610a99838284610d08565b506104df600160fb55565b610aac610e7f565b60c980546001600160a01b0383166001600160a01b03199091168117909155610add6097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b610b1d610e7f565b6001600160a01b0381166104105760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff1615610b775761060883610f79565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa925050508015610bd1575060408051601f3d908101601f19168201909252610bce918101906116b3565b60015b610c345760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b6064820152608401610373565b6000805160206117ad8339815191528114610ca35760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b6064820152608401610373565b50610608838383611015565b600260fb5403610d015760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610373565b600260fb55565b6001600160a01b038216610d2f576040516351eb292760e11b815260040160405180910390fd5b610d436001600160a01b0384168383611040565b816001600160a01b0316836001600160a01b03167f7b09c29f9106defeccc9ac3b823f3aad0b470d120e5df7aed033b5c43a4bf71883604051610d8891815260200190565b60405180910390a3505050565b600160fb55565b6001600160a01b038216610dc3576040516351eb292760e11b815260040160405180910390fd5b6000826001600160a01b03168260405160006040518083038185875af1925050503d8060008114610e10576040519150601f19603f3d011682016040523d82523d6000602084013e610e15565b606091505b5050905080610e3757604051632072c05760e11b815260040160405180910390fd5b826001600160a01b03167fcfdf0683c37c9963bcb0bf5b183df58de6f1eaa52c40da8b1a85df1e7c6397eb83604051610e7291815260200190565b60405180910390a2505050565b6097546001600160a01b03163314610ed95760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e65726044820152606401610373565b565b60c980546001600160a01b031916905561041081611092565b600054610100900460ff16610f1b5760405162461bcd60e51b8152600401610373906116cc565b610ed96110e4565b600054610100900460ff16610f4a5760405162461bcd60e51b8152600401610373906116cc565b610ed9611114565b600054610100900460ff16610ed95760405162461bcd60e51b8152600401610373906116cc565b6001600160a01b0381163b610fe65760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b6064820152608401610373565b6000805160206117ad83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61101e8361113b565b60008251118061102b5750805b156106085761103a838361117b565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526106089084906111a7565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff1661110b5760405162461bcd60e51b8152600401610373906116cc565b610ed933610edb565b600054610100900460ff16610d955760405162461bcd60e51b8152600401610373906116cc565b61114481610f79565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606111a083836040518060600160405280602781526020016117cd6027913961127c565b9392505050565b60006111fc826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166112f49092919063ffffffff16565b905080516000148061121d57508080602001905181019061121d9190611717565b6106085760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610373565b6060600080856001600160a01b031685604051611299919061175d565b600060405180830381855af49150503d80600081146112d4576040519150601f19603f3d011682016040523d82523d6000602084013e6112d9565b606091505b50915091506112ea8683838761130b565b9695505050505050565b60606113038484600085611384565b949350505050565b6060831561137a578251600003611373576001600160a01b0385163b6113735760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610373565b5081611303565b611303838361145f565b6060824710156113e55760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610373565b600080866001600160a01b03168587604051611401919061175d565b60006040518083038185875af1925050503d806000811461143e576040519150601f19603f3d011682016040523d82523d6000602084013e611443565b606091505b50915091506114548783838761130b565b979650505050505050565b81511561146f5781518083602001fd5b8060405162461bcd60e51b81526004016103739190611779565b6001600160a01b038116811461041057600080fd5b6000602082840312156114b057600080fd5b81356111a081611489565b634e487b7160e01b600052604160045260246000fd5b600080604083850312156114e457600080fd5b82356114ef81611489565b9150602083013567ffffffffffffffff8082111561150c57600080fd5b818501915085601f83011261152057600080fd5b813581811115611532576115326114bb565b604051601f8201601f19908116603f0116810190838211818310171561155a5761155a6114bb565b8160405282815288602084870101111561157357600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000806000606084860312156115aa57600080fd5b83356115b581611489565b925060208401356115c581611489565b929592945050506040919091013590565b6000602082840312156115e857600080fd5b5035919050565b6000806040838503121561160257600080fd5b823561160d81611489565b946020939093013593505050565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b6000602082840312156116c557600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b60006020828403121561172957600080fd5b815180151581146111a057600080fd5b60005b8381101561175457818101518382015260200161173c565b50506000910152565b6000825161176f818460208701611739565b9190910192915050565b6020815260008251806020840152611798816040850160208701611739565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212200d4b37ce3f8ab7b0176cd2b969ee847bd86382d9e32b0e2ebeb0d91d6f9aa53264736f6c63430008140033", + "nonce": "0x1e", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionType": "CREATE", + "contractName": "ERC1967Proxy", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": null, + "arguments": [ + "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4", + "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x43736", + "value": "0x0", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c65640000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x1f", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x159175", + "logs": [ + { + "address": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000002000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "gasUsed": "0x159175", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4" + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x459a9", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x0000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x4", + "removed": false + }, + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x5", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000002008002000001000000000000000000000000000000000000020000000000000080000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000080000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "gasUsed": "0x37aac", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b" + } + ], + "libraries": [], + "pending": [], + "returns": { + "proxy": { + "internal_type": "address", + "value": "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" + }, + "impl": { + "internal_type": "address", + "value": "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4" + } + }, + "timestamp": 1787574212713, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/01_DeployVault.s.sol/30/run-latest.json b/broadcast/01_DeployVault.s.sol/30/run-latest.json index 78871c1..929c83b 100644 --- a/broadcast/01_DeployVault.s.sol/30/run-latest.json +++ b/broadcast/01_DeployVault.s.sol/30/run-latest.json @@ -1,10 +1,10 @@ { "transactions": [ { - "hash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", + "hash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", "transactionType": "CREATE", "contractName": "ExitFeeVault", - "contractAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26", + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", "function": null, "arguments": null, "transaction": { @@ -12,28 +12,28 @@ "gas": "0x1c1077", "value": "0x0", "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b608051611829610118600039600081816103350152818161037e0152818161041d0152818161045d015261061a01526118296000f3fe6080604052600436106101025760003560e01c8063715018a611610095578063c4d66de811610064578063c4d66de81461028c578063e30c3978146102ac578063e6d11999146102ca578063f2fde38b146102ea578063f851a4401461030a57600080fd5b8063715018a61461022457806379ba5097146102395780637b1307cd1461024e5780638da5cb5b1461026e57600080fd5b8063503690d1116100d1578063503690d1146101a157806352d1902d146101c1578063557f1473146101e4578063704b6c021461020457600080fd5b80630b5e8df91461010e5780633659cfe61461014c5780634f1ef2861461016e5780634f7a6c3c1461018157600080fd5b3661010957005b600080fd5b34801561011a57600080fd5b5061012d5461012f906001600160a01b031681565b6040516001600160a01b0390911681526020015b60405180910390f35b34801561015857600080fd5b5061016c61016736600461149e565b61032b565b005b61016c61017c3660046114d1565b610413565b34801561018d57600080fd5b5061016c61019c36600461149e565b6104e3565b3480156101ad57600080fd5b5061016c6101bc366004611595565b6105a4565b3480156101cd57600080fd5b506101d661060d565b604051908152602001610143565b3480156101f057600080fd5b5061016c6101ff3660046115d6565b6106c0565b34801561021057600080fd5b5061016c61021f36600461149e565b61074f565b34801561023057600080fd5b5061016c6107c9565b34801561024557600080fd5b5061016c6107e2565b34801561025a57600080fd5b5061016c6102693660046115ef565b610859565b34801561027a57600080fd5b506097546001600160a01b031661012f565b34801561029857600080fd5b5061016c6102a736600461149e565b6108bc565b3480156102b857600080fd5b5060c9546001600160a01b031661012f565b3480156102d657600080fd5b5061016c6102e53660046115ef565b610a14565b3480156102f657600080fd5b5061016c61030536600461149e565b610aa4565b34801561031657600080fd5b5061012e5461012f906001600160a01b031681565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361037c5760405162461bcd60e51b81526004016103739061161b565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166103c56000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146103eb5760405162461bcd60e51b815260040161037390611667565b6103f481610b15565b6040805160008082526020820190925261041091839190610b44565b50565b6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016300361045b5760405162461bcd60e51b81526004016103739061161b565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b03166104a46000805160206117ad833981519152546001600160a01b031690565b6001600160a01b0316146104ca5760405162461bcd60e51b815260040161037390611667565b6104d382610b15565b6104df82826001610b44565b5050565b61012e546001600160a01b0316331480159061050a57506097546001600160a01b03163314155b1561052a5760405163fade6b7760e01b8152336004820152602401610373565b6001600160a01b03811661055157604051630acd20ff60e21b815260040160405180910390fd5b61012d80546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf90600090a35050565b6105ac610caf565b61012e546001600160a01b031633148015906105d357506097546001600160a01b03163314155b156105f35760405163fade6b7760e01b8152336004820152602401610373565b6105fe838383610d08565b610608600160fb55565b505050565b6000306001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016146106ad5760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c00000000000000006064820152608401610373565b506000805160206117ad83398151915290565b6106c8610caf565b61012e546001600160a01b031633148015906106ef57506097546001600160a01b03163314155b1561070f5760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b03168061073a5760405163d3e94d1160e01b815260040160405180910390fd5b6107448183610d9c565b50610410600160fb55565b610757610e7f565b6001600160a01b03811661077e5760405163b325f76760e01b815260040160405180910390fd5b61012e80546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040516317d5c96560e11b815260040160405180910390fd5b60c95433906001600160a01b031681146108505760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b6064820152608401610373565b61041081610edb565b610861610caf565b61012e546001600160a01b0316331480159061088857506097546001600160a01b03163314155b156108a85760405163fade6b7760e01b8152336004820152602401610373565b6108b28282610d9c565b6104df600160fb55565b600054610100900460ff16158080156108dc5750600054600160ff909116105b806108f65750303b1580156108f6575060005460ff166001145b6109595760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b6064820152608401610373565b6000805460ff19166001179055801561097c576000805461ff0019166101001790555b610984610ef4565b61098c610ef4565b610994610f23565b61099c610f52565b6001600160a01b038216158015906109bd57506001600160a01b0382163314155b156109cb576109cb82610edb565b80156104df576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610a1c610caf565b61012e546001600160a01b03163314801590610a4357506097546001600160a01b03163314155b15610a635760405163fade6b7760e01b8152336004820152602401610373565b61012d546001600160a01b031680610a8e5760405163d3e94d1160e01b815260040160405180910390fd5b610a99838284610d08565b506104df600160fb55565b610aac610e7f565b60c980546001600160a01b0383166001600160a01b03199091168117909155610add6097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b610b1d610e7f565b6001600160a01b0381166104105760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff1615610b775761060883610f79565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa925050508015610bd1575060408051601f3d908101601f19168201909252610bce918101906116b3565b60015b610c345760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b6064820152608401610373565b6000805160206117ad8339815191528114610ca35760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b6064820152608401610373565b50610608838383611015565b600260fb5403610d015760405162461bcd60e51b815260206004820152601f60248201527f5265656e7472616e637947756172643a207265656e7472616e742063616c6c006044820152606401610373565b600260fb55565b6001600160a01b038216610d2f576040516351eb292760e11b815260040160405180910390fd5b610d436001600160a01b0384168383611040565b816001600160a01b0316836001600160a01b03167f7b09c29f9106defeccc9ac3b823f3aad0b470d120e5df7aed033b5c43a4bf71883604051610d8891815260200190565b60405180910390a3505050565b600160fb55565b6001600160a01b038216610dc3576040516351eb292760e11b815260040160405180910390fd5b6000826001600160a01b03168260405160006040518083038185875af1925050503d8060008114610e10576040519150601f19603f3d011682016040523d82523d6000602084013e610e15565b606091505b5050905080610e3757604051632072c05760e11b815260040160405180910390fd5b826001600160a01b03167fcfdf0683c37c9963bcb0bf5b183df58de6f1eaa52c40da8b1a85df1e7c6397eb83604051610e7291815260200190565b60405180910390a2505050565b6097546001600160a01b03163314610ed95760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e65726044820152606401610373565b565b60c980546001600160a01b031916905561041081611092565b600054610100900460ff16610f1b5760405162461bcd60e51b8152600401610373906116cc565b610ed96110e4565b600054610100900460ff16610f4a5760405162461bcd60e51b8152600401610373906116cc565b610ed9611114565b600054610100900460ff16610ed95760405162461bcd60e51b8152600401610373906116cc565b6001600160a01b0381163b610fe65760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b6064820152608401610373565b6000805160206117ad83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61101e8361113b565b60008251118061102b5750805b156106085761103a838361117b565b50505050565b604080516001600160a01b038416602482015260448082018490528251808303909101815260649091019091526020810180516001600160e01b031663a9059cbb60e01b1790526106089084906111a7565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff1661110b5760405162461bcd60e51b8152600401610373906116cc565b610ed933610edb565b600054610100900460ff16610d955760405162461bcd60e51b8152600401610373906116cc565b61114481610f79565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606111a083836040518060600160405280602781526020016117cd6027913961127c565b9392505050565b60006111fc826040518060400160405280602081526020017f5361666545524332303a206c6f772d6c6576656c2063616c6c206661696c6564815250856001600160a01b03166112f49092919063ffffffff16565b905080516000148061121d57508080602001905181019061121d9190611717565b6106085760405162461bcd60e51b815260206004820152602a60248201527f5361666545524332303a204552433230206f7065726174696f6e20646964206e6044820152691bdd081cdd58d8d9595960b21b6064820152608401610373565b6060600080856001600160a01b031685604051611299919061175d565b600060405180830381855af49150503d80600081146112d4576040519150601f19603f3d011682016040523d82523d6000602084013e6112d9565b606091505b50915091506112ea8683838761130b565b9695505050505050565b60606113038484600085611384565b949350505050565b6060831561137a578251600003611373576001600160a01b0385163b6113735760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610373565b5081611303565b611303838361145f565b6060824710156113e55760405162461bcd60e51b815260206004820152602660248201527f416464726573733a20696e73756666696369656e742062616c616e636520666f6044820152651c8818d85b1b60d21b6064820152608401610373565b600080866001600160a01b03168587604051611401919061175d565b60006040518083038185875af1925050503d806000811461143e576040519150601f19603f3d011682016040523d82523d6000602084013e611443565b606091505b50915091506114548783838761130b565b979650505050505050565b81511561146f5781518083602001fd5b8060405162461bcd60e51b81526004016103739190611779565b6001600160a01b038116811461041057600080fd5b6000602082840312156114b057600080fd5b81356111a081611489565b634e487b7160e01b600052604160045260246000fd5b600080604083850312156114e457600080fd5b82356114ef81611489565b9150602083013567ffffffffffffffff8082111561150c57600080fd5b818501915085601f83011261152057600080fd5b813581811115611532576115326114bb565b604051601f8201601f19908116603f0116810190838211818310171561155a5761155a6114bb565b8160405282815288602084870101111561157357600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b6000806000606084860312156115aa57600080fd5b83356115b581611489565b925060208401356115c581611489565b929592945050506040919091013590565b6000602082840312156115e857600080fd5b5035919050565b6000806040838503121561160257600080fd5b823561160d81611489565b946020939093013593505050565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b6000602082840312156116c557600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b60006020828403121561172957600080fd5b815180151581146111a057600080fd5b60005b8381101561175457818101518382015260200161173c565b50506000910152565b6000825161176f818460208701611739565b9190910192915050565b6020815260008251806020840152611798816040850160208701611739565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212200d4b37ce3f8ab7b0176cd2b969ee847bd86382d9e32b0e2ebeb0d91d6f9aa53264736f6c63430008140033", - "nonce": "0x2", + "nonce": "0x1e", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", + "hash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", "transactionType": "CREATE", "contractName": "ERC1967Proxy", - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": null, "arguments": [ - "0xab3761D0800C4310414e75Fd8b545cE094C20e26", + "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4", "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x43736", "value": "0x0", - "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564000000000000000000000000ab3761d0800c4310414e75fd8b545ce094c20e2600000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", - "nonce": "0x3", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c65640000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x1f", "chainId": "0x1e" }, "additionalContracts": [], @@ -43,122 +43,122 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x177067", + "cumulativeGasUsed": "0x159175", "logs": [ { - "address": "0xab3761d0800c4310414e75fd8b545ce094c20e26", + "address": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", - "blockHash": "0xb507102ee8ba234405a69ce47d02551794e923f9604233ca095a6dd9e9df3311", - "blockNumber": "0x8b8d9a", - "transactionHash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", - "transactionIndex": "0x1", - "logIndex": "0x1", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000040000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000002000000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x1c022377d73c722793ec3a2f71c16c86354e7c584792b04760bb9785ee674ea7", - "transactionIndex": "0x1", - "blockHash": "0xb507102ee8ba234405a69ce47d02551794e923f9604233ca095a6dd9e9df3311", - "blockNumber": "0x8b8d9a", + "transactionHash": "0x3a316635e88398ae123158e8ecff02f1d85a2f07c293652bbb1a602ff7ae1938", + "transactionIndex": "0x0", + "blockHash": "0xf2cf83e04873625a7c7747d57ff18a164537e983a4da7a39732ffe3a22a9821e", + "blockNumber": "0x8c0fec", "gasUsed": "0x159175", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26" + "contractAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4" }, { "status": "0x1", - "cumulativeGasUsed": "0x6d292", + "cumulativeGasUsed": "0x459a9", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", - "0x000000000000000000000000ab3761d0800c4310414e75fd8b545ce094c20e26" + "0x0000000000000000000000008f977f4c9dccce1a0306a34944c0460e4445deb4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x5", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x6", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x3", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x7", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x4", "removed": false }, { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "logIndex": "0x8", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "logIndex": "0x5", "removed": false } ], - "logsBloom": "0x00000000000000000000000000002000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000001000000000000000000000000000000000001020000000000004000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000004000000000020000000000030000000000000000000060000000000000000000000000000000020000000020000000001000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000400000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000002008002000001000000000000000000000000000000000000020000000000000080000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000080000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "transactionIndex": "0x4", - "blockHash": "0x236ab8c21d4456dcc510972760178f6b6988bf17fcd8df14e2f9797c8a2c6735", - "blockNumber": "0x8b8d9c", + "transactionHash": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "transactionIndex": "0x1", + "blockHash": "0x3962d662b938557262d02a808ffda6bec1922dc9d5de1b71b2abdd85959ce7ab", + "blockNumber": "0x8c0fed", "gasUsed": "0x37aac", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08" + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b" } ], "libraries": [], "pending": [], "returns": { - "impl": { + "proxy": { "internal_type": "address", - "value": "0xab3761D0800C4310414e75Fd8b545cE094C20e26" + "value": "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" }, - "proxy": { + "impl": { "internal_type": "address", - "value": "0x2ba389B021fA4A5F50cc1758EFD23Ca066d0Be08" + "value": "0x8f977F4c9dCcCE1a0306A34944C0460e4445deB4" } }, - "timestamp": 1786565025004, + "timestamp": 1787574212713, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json b/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json new file mode 100644 index 0000000..5a69cb9 --- /dev/null +++ b/broadcast/02_BootstrapVault.s.sol/30/run-1787576893913.json @@ -0,0 +1,170 @@ +{ + "transactions": [ + { + "hash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "setDefaultRecipient(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x12767", + "value": "0x0", + "input": "0x4f7a6c3c000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x20", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "setAdmin(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x12a51", + "value": "0x0", + "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x21", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "function": "transferOwnership(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "gas": "0x11c78", + "value": "0x0", + "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x22", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x123d0", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000020000000000000000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "gasUsed": "0xb0c3", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x1bc1b", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "gasUsed": "0xae4b", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0xb0a8", + "logs": [ + { + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "topics": [ + "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "gasUsed": "0xb0a8", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1787576893913, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/02_BootstrapVault.s.sol/30/run-latest.json b/broadcast/02_BootstrapVault.s.sol/30/run-latest.json index 4222bac..5a69cb9 100644 --- a/broadcast/02_BootstrapVault.s.sol/30/run-latest.json +++ b/broadcast/02_BootstrapVault.s.sol/30/run-latest.json @@ -1,63 +1,63 @@ { "transactions": [ { - "hash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", + "hash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "setDefaultRecipient(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x12767", "value": "0x0", "input": "0x4f7a6c3c000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x4", + "nonce": "0x20", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", + "hash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "setAdmin(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x12a51", "value": "0x0", "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x5", + "nonce": "0x21", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", + "hash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "contractAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "function": "transferOwnership(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "gas": "0x11c78", "value": "0x0", "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x6", + "nonce": "0x22", "chainId": "0x1e" }, "additionalContracts": [], @@ -67,104 +67,104 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x26b5c", + "cumulativeGasUsed": "0x123d0", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x825991f9f9f950375f4d3f04c94283f5f7ea10aa441c6d43cb0e4c7bdce517bf", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x26bde7abb5498d99745d287576590af0aa182c3344fda6bdf3f7b2a7661ff4f8", - "blockNumber": "0x8b8dbd", - "transactionHash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001020000000000004000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000010000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000020000000000000000000800000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000008000000020000000000000000000000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0x513f5e5378f482cfceee996355c0ddd340561f550ab3bb5c09846361f704d6d4", - "transactionIndex": "0x2", - "blockHash": "0x26bde7abb5498d99745d287576590af0aa182c3344fda6bdf3f7b2a7661ff4f8", - "blockNumber": "0x8b8dbd", + "transactionHash": "0xe6f566c9f43ce2c6c23df96a89fa89596a5e4b639df6e15bbee5b4f3db5cac5f", + "transactionIndex": "0x1", + "blockHash": "0x1578398d14d2651f8e2724ae3670934db5601dbd17e2fb3fc9e37edd236c7d59", + "blockNumber": "0x8c1043", "gasUsed": "0xb0c3", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x2d8a8", + "cumulativeGasUsed": "0x1bc1b", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x1ab1a6f771546bbaf761f64687f14b4a3668addf60febc4692921a1c1b0e06bd", - "blockNumber": "0x8b8dbf", - "transactionHash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", - "transactionIndex": "0x3", - "logIndex": "0x5", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080400000000001000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000010000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xf230593c4ea325a3aa257b8269f43c96d26d18daf0e7bc0940471ddf432a5d91", - "transactionIndex": "0x3", - "blockHash": "0x1ab1a6f771546bbaf761f64687f14b4a3668addf60febc4692921a1c1b0e06bd", - "blockNumber": "0x8b8dbf", + "transactionHash": "0xa8a450f59bb86926831c48bcf602e4ee338f47f124aaf5265111da881c92ff6b", + "transactionIndex": "0x1", + "blockHash": "0x857503bd3a049eb497b03e1c2344f99eae96fc51ee3260577778083038ec9d4e", + "blockNumber": "0x8c1045", "gasUsed": "0xae4b", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x19474", + "cumulativeGasUsed": "0xb0a8", "logs": [ { - "address": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "address": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "topics": [ "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x75acca46b89fa1f9512f5e86e10c8eeda4838db4a3050234c2d112c10afedd45", - "blockNumber": "0x8b8dc0", - "transactionHash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", - "transactionIndex": "0x2", - "logIndex": "0x1", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000004000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002010000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002008000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0x8e76efc7a0f57095ca51e5508a87ae55b6262c9e03d2154bb573e3211f0b5018", - "transactionIndex": "0x2", - "blockHash": "0x75acca46b89fa1f9512f5e86e10c8eeda4838db4a3050234c2d112c10afedd45", - "blockNumber": "0x8b8dc0", + "transactionHash": "0x7f70ae7ee8913c85be6136ca28f864f0c4e72645e203407d2ddc6f3e07a05f38", + "transactionIndex": "0x0", + "blockHash": "0xd01f6d502c6b220a699bad21b26311fd6688cf14b2e9942e7a3205c433b31da0", + "blockNumber": "0x8c1047", "gasUsed": "0xb0a8", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", + "to": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", "contractAddress": null } ], "libraries": [], "pending": [], "returns": {}, - "timestamp": 1786566135969, + "timestamp": 1787576893913, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/03_DeployController.s.sol/30/run-1787577343971.json b/broadcast/03_DeployController.s.sol/30/run-1787577343971.json new file mode 100644 index 0000000..bd0de1d --- /dev/null +++ b/broadcast/03_DeployController.s.sol/30/run-1787577343971.json @@ -0,0 +1,164 @@ +{ + "transactions": [ + { + "hash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionType": "CREATE", + "contractName": "ExitFeeController", + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "function": null, + "arguments": null, + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x292425", + "value": "0x0", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a2646970667358221220da9ed0469f75959621b7a20bdba0cd03e50cb0089e2b7c8010bf46a16479d97764736f6c63430008140033", + "nonce": "0x23", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionType": "CREATE", + "contractName": "ERC1967Proxy", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": null, + "arguments": [ + "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d", + "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "gas": "0x3c5b8", + "value": "0x0", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x24", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x20f9c8", + "logs": [ + { + "address": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000008000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", + "transactionIndex": "0x1", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "gasUsed": "0x1fa157", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d" + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x5fa9b", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", + "0x00000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" + ], + "data": "0x", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + }, + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100400000000000000000800000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000002000001000000000000000000000000000000100000024000000000000000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001001000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "gasUsed": "0x32acb", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e" + } + ], + "libraries": [], + "pending": [], + "returns": { + "impl": { + "internal_type": "address", + "value": "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d" + }, + "proxy": { + "internal_type": "address", + "value": "0x99994b4522483DE17F31a5bC010c5901AdD3440E" + } + }, + "timestamp": 1787577343971, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/03_DeployController.s.sol/30/run-latest.json b/broadcast/03_DeployController.s.sol/30/run-latest.json index d9bc861..bd0de1d 100644 --- a/broadcast/03_DeployController.s.sol/30/run-latest.json +++ b/broadcast/03_DeployController.s.sol/30/run-latest.json @@ -1,39 +1,39 @@ { "transactions": [ { - "hash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "hash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionType": "CREATE", "contractName": "ExitFeeController", - "contractAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", "function": null, "arguments": null, "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x292425", "value": "0x0", - "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a26469706673582212208cb15c168403fa3e55f3ae6f16bf941b0eb503902d97afc4a8482f6de492250c64736f6c63430008140033", - "nonce": "0x7", + "input": "0x60a06040523060805234801561001457600080fd5b5061001d610022565b6100e1565b600054610100900460ff161561008e5760405162461bcd60e51b815260206004820152602760248201527f496e697469616c697a61626c653a20636f6e747261637420697320696e697469604482015266616c697a696e6760c81b606482015260840160405180910390fd5b60005460ff908116146100df576000805460ff191660ff9081179091556040519081527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b565b60805161241862000119600039600081816107a2015281816107eb015281816108a2015281816108e2015261097501526124186000f3fe6080604052600436106101d75760003560e01c80638da5cb5b11610102578063d131f85511610095578063f2fde38b11610064578063f2fde38b1461060c578063f851a4401461062c578063fd967f471461064d578063ff78c4361461067657600080fd5b8063d131f8551461058e578063e30c3978146105ae578063eeb57de7146105cc578063efdcd974146105ec57600080fd5b8063baf2785d116100d1578063baf2785d1461050e578063bda0b1bf1461052e578063c4d66de81461054e578063c5906c671461056e57600080fd5b80638da5cb5b14610434578063a48efa1c14610466578063af65b179146104c9578063b3f00674146104e957600080fd5b806364a972c71161017a578063762dc30611610149578063762dc306146103b257806379ba5097146103df57806380569670146103f4578063809c30131461041457600080fd5b806364a972c7146102e4578063704b6c021461030457806370db067514610324578063715018a61461039d57600080fd5b80633659cfe6116101b65780633659cfe61461026e578063418ecb561461028e5780634f1ef286146102ae57806352d1902d146102c157600080fd5b8062ee5a76146101dc5780630f8886951461020b578063292630781461024c575b600080fd5b3480156101e857600080fd5b5060fb546101f69060ff1681565b60405190151581526020015b60405180910390f35b34801561021757600080fd5b5061022b610226366004611d65565b6106e7565b6040805182511515815260209283015161ffff169281019290925201610202565b34801561025857600080fd5b5061026c610267366004611ddd565b61073d565b005b34801561027a57600080fd5b5061026c610289366004611e29565b610798565b34801561029a57600080fd5b5061026c6102a9366004611e5c565b610880565b61026c6102bc366004611eaf565b610898565b3480156102cd57600080fd5b506102d6610968565b604051908152602001610202565b3480156102f057600080fd5b5061026c6102ff366004611ddd565b610a1b565b34801561031057600080fd5b5061026c61031f366004611e29565b610a6f565b34801561033057600080fd5b5061034461033f366004611f71565b610ae9565b604051610202919081511515815260208083015161ffff169082015260408083015190820152606080830151908201526080808301516001600160a01b03169082015260a09182015160ff169181019190915260c00190565b3480156103a957600080fd5b5061026c610c16565b3480156103be57600080fd5b506103d26103cd366004611fb5565b610c2f565b6040516102029190611fce565b3480156103eb57600080fd5b5061026c610c49565b34801561040057600080fd5b5061026c61040f366004612029565b610cc0565b34801561042057600080fd5b5061026c61042f366004612046565b610d4e565b34801561044057600080fd5b506097546001600160a01b03165b6040516001600160a01b039091168152602001610202565b34801561047257600080fd5b5061022b610481366004611fb5565b604080518082019091526000808252602082015250600090815260fc602090815260409182902082518084019093525460ff811615158352610100900461ffff169082015290565b3480156104d557600080fd5b5061026c6104e4366004611d65565b610de6565b3480156104f557600080fd5b5060fb5461044e9061010090046001600160a01b031681565b34801561051a57600080fd5b506103d2610529366004611fb5565b610df8565b34801561053a57600080fd5b5061026c610549366004612046565b610e13565b34801561055a57600080fd5b5061026c610569366004611e29565b610ea2565b34801561057a57600080fd5b5061026c610589366004611d65565b610ff2565b34801561059a57600080fd5b5061026c6105a93660046120ed565b611004565b3480156105ba57600080fd5b5060c9546001600160a01b031661044e565b3480156105d857600080fd5b5061026c6105e7366004611e5c565b6110d6565b3480156105f857600080fd5b5061026c610607366004611e29565b6110e9565b34801561061857600080fd5b5061026c610627366004611e29565b6111a9565b34801561063857600080fd5b506101015461044e906001600160a01b031681565b34801561065957600080fd5b5061066361271081565b60405161ffff9091168152602001610202565b34801561068257600080fd5b5061022b610691366004611d65565b6040805180820182526000808252602091820181905293845260fe81528184206001600160a01b039390931684529182529182902082518084019093525460ff811615158352610100900461ffff169082015290565b6040805180820182526000808252602091820181905284815260fd82528281206001600160a01b0385168252825282902082518084019093525460ff811615158352610100900461ffff16908201525b92915050565b61074561121a565b8060005b81811015610791576107818585858481811061076757610767612111565b905060200201602081019061077c9190611e29565b611276565b61078a8161213d565b9050610749565b5050505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036107e95760405162461bcd60e51b81526004016107e090612156565b60405180910390fd5b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661083260008051602061239c833981519152546001600160a01b031690565b6001600160a01b0316146108585760405162461bcd60e51b81526004016107e0906121a2565b61086181611314565b6040805160008082526020820190925261087d91839190611343565b50565b61088861121a565b6108938383836114ae565b505050565b6001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001630036108e05760405162461bcd60e51b81526004016107e090612156565b7f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031661092960008051602061239c833981519152546001600160a01b031690565b6001600160a01b03161461094f5760405162461bcd60e51b81526004016107e0906121a2565b61095882611314565b61096482826001611343565b5050565b6000306001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001614610a085760405162461bcd60e51b815260206004820152603860248201527f555550535570677261646561626c653a206d757374206e6f742062652063616c60448201527f6c6564207468726f7567682064656c656761746563616c6c000000000000000060648201526084016107e0565b5060008051602061239c83398151915290565b610a2361121a565b8060005b8181101561079157610a5f85858584818110610a4557610a45612111565b9050602002016020810190610a5a9190611e29565b6115b6565b610a688161213d565b9050610a27565b610a7761121a565b6001600160a01b038116610a9e5760405163b325f76760e01b815260040160405180910390fd5b61010180546001600160a01b0319166001600160a01b0383169081179091556040517f8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c90600090a250565b6040805160c08101825260008082526020820181905291810182905260a081019190915260fb5461010081046001600160a01b031660808301526060820183905260ff16610b415760015b60ff1660a0820152610c0e565b60fb5461010090046001600160a01b0316610b5d576002610b34565b6000610b6a868686611655565b8051909150610b845760025b60ff1660a083015250610c0e565b610b926127106000196121ee565b831115610ba0576003610b76565b602081015160009061271090610bba9061ffff1686612210565b610bc491906121ee565b905083811115610bdc575050600360a0820152610c0e565b6001835260208083015161ffff169084015260408301819052610bff8185612227565b60608401525050600060a08201525b949350505050565b6040516317d5c96560e11b815260040160405180910390fd5b600081815260ff602052604090206060906107379061176a565b60c95433906001600160a01b03168114610cb75760405162461bcd60e51b815260206004820152602960248201527f4f776e61626c6532537465703a2063616c6c6572206973206e6f7420746865206044820152683732bb9037bbb732b960b91b60648201526084016107e0565b61087d81611777565b610101546001600160a01b03163314801590610ce757506097546001600160a01b03163314155b15610d075760405163fade6b7760e01b81523360048201526024016107e0565b60fb805460ff19168215159081179091556040519081527f4afba55e68999eb85b2692f998f6eea3e471317ba84d323f927b08ee304937dc9060200160405180910390a150565b610d5661121a565b82818114610d7a576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610dcd87878784818110610d9b57610d9b612111565b9050602002016020810190610db09190611e29565b868685818110610dc257610dc2612111565b9050604002016114ae565b610dd68161213d565b9050610d7d565b50505050505050565b610dee61121a565b6109648282611276565b6000818152610100602052604090206060906107379061176a565b610e1b61121a565b82818114610e3f576040516001621398b960e31b0319815260040160405180910390fd5b60005b81811015610ddd57610e9287878784818110610e6057610e60612111565b9050602002016020810190610e759190611e29565b868685818110610e8757610e87612111565b905060400201611790565b610e9b8161213d565b9050610e42565b600054610100900460ff1615808015610ec25750600054600160ff909116105b80610edc5750303b158015610edc575060005460ff166001145b610f3f5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201526d191e481a5b9a5d1a585b1a5e995960921b60648201526084016107e0565b6000805460ff191660011790558015610f62576000805461ff0019166101001790555b610f6a611867565b610f72611867565b610f7a611896565b6001600160a01b03821615801590610f9b57506001600160a01b0382163314155b15610fa957610fa982611777565b8015610964576000805461ff0019169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15050565b610ffa61121a565b61096482826115b6565b61100c61121a565b61271061101f604083016020840161224a565b61ffff16111561105957611039604082016020830161224a565b60405163e476e19360e01b815261ffff90911660048201526024016107e0565b600082815260fc6020526040902081906110738282612267565b508290507f9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e46110a56020840184612029565b6110b5604085016020860161224a565b60408051921515835261ffff90911660208301520160405180910390a25050565b6110de61121a565b610893838383611790565b610101546001600160a01b0316331480159061111057506097546001600160a01b03163314155b156111305760405163fade6b7760e01b81523360048201526024016107e0565b6001600160a01b03811661115757604051630665334760e11b815260040160405180910390fd5b60fb8054610100600160a81b0319166101006001600160a01b038416908102919091179091556040517fbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d90600090a250565b6111b161121a565b60c980546001600160a01b0383166001600160a01b031990911681179091556111e26097546001600160a01b031690565b6001600160a01b03167f38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e2270060405160405180910390a350565b6097546001600160a01b031633146112745760405162461bcd60e51b815260206004820181905260248201527f4f776e61626c653a2063616c6c6572206973206e6f7420746865206f776e657260448201526064016107e0565b565b6001600160a01b03811661129d5760405163862f9ab160e01b815260040160405180910390fd5b600082815260ff602052604090206112b590826118bd565b1561096457600082815260fd602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917faafabd0077e7cf783725deb87142797a6a87e42f2afff85325d1b6912eb71d419190a35050565b61131c61121a565b6001600160a01b03811661087d5760405163022771b760e31b815260040160405180910390fd5b7f4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd91435460ff161561137657610893836118d2565b826001600160a01b03166352d1902d6040518163ffffffff1660e01b8152600401602060405180830381865afa9250505080156113d0575060408051601f3d908101601f191682019092526113cd918101906122ae565b60015b6114335760405162461bcd60e51b815260206004820152602e60248201527f45524331393637557067726164653a206e657720696d706c656d656e7461746960448201526d6f6e206973206e6f74205555505360901b60648201526084016107e0565b60008051602061239c83398151915281146114a25760405162461bcd60e51b815260206004820152602960248201527f45524331393637557067726164653a20756e737570706f727465642070726f786044820152681a58589b195555525160ba1b60648201526084016107e0565b5061089383838361196e565b6001600160a01b0382166114d55760405163862f9ab160e01b815260040160405180910390fd5b6127106114e8604083016020840161224a565b61ffff16111561150257611039604082016020830161224a565b600083815260fd602090815260408083206001600160a01b0386168452909152902081906115308282612267565b5050600083815260ff6020526040902061154a9083611999565b506001600160a01b038216837f48d8c51d6d2a265f877d8d087866ac5804efe2d6770ba1dd2d196eeb23630c906115846020850185612029565b611594604086016020870161224a565b60408051921515835261ffff90911660208301520160405180910390a3505050565b6001600160a01b0381166115dd5760405163069018bf60e11b815260040160405180910390fd5b6000828152610100602052604090206115f690826118bd565b1561096457600082815260fe602090815260408083206001600160a01b0385168085529252808320805462ffffff1916905551909184917f8a7413f0bcdbe90171aa0fcf0ebeb97f0c2dcee8e972861cb6b54a412041c82c9190a35050565b6040805180820190915260008082526020820152600084815260fc602090815260409182902082518084019093525460ff8116151580845261010090910461ffff16918301919091526116a9579050611763565b600085815260fe602090815260408083206001600160a01b038716845282529182902082518084019093525460ff8116158015845261010090910461ffff16918301919091526116fc5791506117639050565b6001600160a01b0385161561175f5750600085815260fd602090815260408083206001600160a01b038816845282529182902082518084019093525460ff8116158015845261010090910461ffff169183019190915261175f5791506117639050565b5090505b9392505050565b60606000611763836119ae565b60c980546001600160a01b031916905561087d81611a0a565b6001600160a01b0382166117b75760405163069018bf60e11b815260040160405180910390fd5b6127106117ca604083016020840161224a565b61ffff1611156117e457611039604082016020830161224a565b600083815260fe602090815260408083206001600160a01b0386168452909152902081906118128282612267565b505060008381526101006020526040902061182d9083611999565b506001600160a01b038216837fb5938f0c738d351d053db88baaeda5f2b265cd07e3b930e9182ebebd5f701d476115846020850185612029565b600054610100900460ff1661188e5760405162461bcd60e51b81526004016107e0906122c7565b611274611a5c565b600054610100900460ff166112745760405162461bcd60e51b81526004016107e0906122c7565b6000611763836001600160a01b038416611a8c565b6001600160a01b0381163b61193f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084016107e0565b60008051602061239c83398151915280546001600160a01b0319166001600160a01b0392909216919091179055565b61197783611b7f565b6000825111806119845750805b15610893576119938383611bbf565b50505050565b6000611763836001600160a01b038416611be4565b6060816000018054806020026020016040519081016040528092919081815260200182805480156119fe57602002820191906000526020600020905b8154815260200190600101908083116119ea575b50505050509050919050565b609780546001600160a01b038381166001600160a01b0319831681179093556040519116919082907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e090600090a35050565b600054610100900460ff16611a835760405162461bcd60e51b81526004016107e0906122c7565b61127433611777565b60008181526001830160205260408120548015611b75576000611ab0600183612227565b8554909150600090611ac490600190612227565b9050818114611b29576000866000018281548110611ae457611ae4612111565b9060005260206000200154905080876000018481548110611b0757611b07612111565b6000918252602080832090910192909255918252600188019052604090208390555b8554869080611b3a57611b3a612312565b600190038181906000526020600020016000905590558560010160008681526020019081526020016000206000905560019350505050610737565b6000915050610737565b611b88816118d2565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b606061176383836040518060600160405280602781526020016123bc60279139611c33565b6000818152600183016020526040812054611c2b57508154600181810184556000848152602080822090930184905584548482528286019093526040902091909155610737565b506000610737565b6060600080856001600160a01b031685604051611c50919061234c565b600060405180830381855af49150503d8060008114611c8b576040519150601f19603f3d011682016040523d82523d6000602084013e611c90565b606091505b5091509150611ca186838387611cab565b9695505050505050565b60608315611d1a578251600003611d13576001600160a01b0385163b611d135760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e747261637400000060448201526064016107e0565b5081610c0e565b610c0e8383815115611d2f5781518083602001fd5b8060405162461bcd60e51b81526004016107e09190612368565b80356001600160a01b0381168114611d6057600080fd5b919050565b60008060408385031215611d7857600080fd5b82359150611d8860208401611d49565b90509250929050565b60008083601f840112611da357600080fd5b50813567ffffffffffffffff811115611dbb57600080fd5b6020830191508360208260051b8501011115611dd657600080fd5b9250929050565b600080600060408486031215611df257600080fd5b83359250602084013567ffffffffffffffff811115611e1057600080fd5b611e1c86828701611d91565b9497909650939450505050565b600060208284031215611e3b57600080fd5b61176382611d49565b600060408284031215611e5657600080fd5b50919050565b600080600060808486031215611e7157600080fd5b83359250611e8160208501611d49565b9150611e908560408601611e44565b90509250925092565b634e487b7160e01b600052604160045260246000fd5b60008060408385031215611ec257600080fd5b611ecb83611d49565b9150602083013567ffffffffffffffff80821115611ee857600080fd5b818501915085601f830112611efc57600080fd5b813581811115611f0e57611f0e611e99565b604051601f8201601f19908116603f01168101908382118183101715611f3657611f36611e99565b81604052828152886020848701011115611f4f57600080fd5b8260208601602083013760006020848301015280955050505050509250929050565b60008060008060808587031215611f8757600080fd5b84359350611f9760208601611d49565b9250611fa560408601611d49565b9396929550929360600135925050565b600060208284031215611fc757600080fd5b5035919050565b6020808252825182820181905260009190848201906040850190845b8181101561200f5783516001600160a01b031683529284019291840191600101611fea565b50909695505050505050565b801515811461087d57600080fd5b60006020828403121561203b57600080fd5b81356117638161201b565b60008060008060006060868803121561205e57600080fd5b85359450602086013567ffffffffffffffff8082111561207d57600080fd5b61208989838a01611d91565b909650945060408801359150808211156120a257600080fd5b818801915088601f8301126120b657600080fd5b8135818111156120c557600080fd5b8960208260061b85010111156120da57600080fd5b9699959850939650602001949392505050565b6000806060838503121561210057600080fd5b82359150611d888460208501611e44565b634e487b7160e01b600052603260045260246000fd5b634e487b7160e01b600052601160045260246000fd5b60006001820161214f5761214f612127565b5060010190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b19195b1959d85d1958d85b1b60a21b606082015260800190565b6020808252602c908201527f46756e6374696f6e206d7573742062652063616c6c6564207468726f7567682060408201526b6163746976652070726f787960a01b606082015260800190565b60008261220b57634e487b7160e01b600052601260045260246000fd5b500490565b808202811582820484141761073757610737612127565b8181038181111561073757610737612127565b61ffff8116811461087d57600080fd5b60006020828403121561225c57600080fd5b81356117638161223a565b81356122728161201b565b815460ff19811691151560ff16918217835560208401356122928161223a565b62ffff008160081b168362ffffff198416171784555050505050565b6000602082840312156122c057600080fd5b5051919050565b6020808252602b908201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960408201526a6e697469616c697a696e6760a81b606082015260800190565b634e487b7160e01b600052603160045260246000fd5b60005b8381101561234357818101518382015260200161232b565b50506000910152565b6000825161235e818460208701612328565b9190910192915050565b6020815260008251806020840152612387816040850160208701612328565b601f01601f1916919091016040019291505056fe360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c6564a2646970667358221220da9ed0469f75959621b7a20bdba0cd03e50cb0089e2b7c8010bf46a16479d97764736f6c63430008140033", + "nonce": "0x23", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", + "hash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", "transactionType": "CREATE", "contractName": "ERC1967Proxy", - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": null, "arguments": [ - "0x33EF630510Ba4d5e13Cfc3a49Ad35BEf5c9c2604", + "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d", "0xc4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "gas": "0x3c5b8", "value": "0x0", - "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000033ef630510ba4d5e13cfc3a49ad35bef5c9c260400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", - "nonce": "0x8", + "input": "0x60806040526040516104e13803806104e1833981016040819052610022916102de565b61002e82826000610035565b50506103fb565b61003e83610061565b60008251118061004b5750805b1561005c5761005a83836100a1565b505b505050565b61006a816100cd565b6040516001600160a01b038216907fbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b90600090a250565b60606100c683836040518060600160405280602781526020016104ba60279139610180565b9392505050565b6001600160a01b0381163b61013f5760405162461bcd60e51b815260206004820152602d60248201527f455243313936373a206e657720696d706c656d656e746174696f6e206973206e60448201526c1bdd08184818dbdb9d1c9858dd609a1b60648201526084015b60405180910390fd5b7f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc80546001600160a01b0319166001600160a01b0392909216919091179055565b6060600080856001600160a01b03168560405161019d91906103ac565b600060405180830381855af49150503d80600081146101d8576040519150601f19603f3d011682016040523d82523d6000602084013e6101dd565b606091505b5090925090506101ef868383876101f9565b9695505050505050565b60608315610268578251600003610261576001600160a01b0385163b6102615760405162461bcd60e51b815260206004820152601d60248201527f416464726573733a2063616c6c20746f206e6f6e2d636f6e74726163740000006044820152606401610136565b5081610272565b610272838361027a565b949350505050565b81511561028a5781518083602001fd5b8060405162461bcd60e51b815260040161013691906103c8565b634e487b7160e01b600052604160045260246000fd5b60005b838110156102d55781810151838201526020016102bd565b50506000910152565b600080604083850312156102f157600080fd5b82516001600160a01b038116811461030857600080fd5b60208401519092506001600160401b038082111561032557600080fd5b818501915085601f83011261033957600080fd5b81518181111561034b5761034b6102a4565b604051601f8201601f19908116603f01168101908382118183101715610373576103736102a4565b8160405282815288602084870101111561038c57600080fd5b61039d8360208301602088016102ba565b80955050505050509250929050565b600082516103be8184602087016102ba565b9190910192915050565b60208152600082518060208401526103e78160408501602087016102ba565b601f01601f19169190910160400192915050565b60b1806104096000396000f3fe608060405236601057600e6013565b005b600e5b601f601b6021565b6058565b565b600060537f360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc546001600160a01b031690565b905090565b3660008037600080366000845af43d6000803e8080156076573d6000f35b3d6000fdfea264697066735822122092e2b7b76aef8e668c0c8fff391f60956ba404ab8448bd690672d9a64f7f238864736f6c63430008140033416464726573733a206c6f772d6c6576656c2064656c65676174652063616c6c206661696c656400000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000024c4d66de8000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c400000000000000000000000000000000000000000000000000000000", + "nonce": "0x24", "chainId": "0x1e" }, "additionalContracts": [], @@ -43,122 +43,122 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0x1ffc8d", + "cumulativeGasUsed": "0x20f9c8", "logs": [ { - "address": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", + "address": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000ff", - "blockHash": "0xcccca4d450248072417853097c3b0acd7be96aa9b34b4dc75791f0963809814a", - "blockNumber": "0x8b8e14", - "transactionHash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionIndex": "0x1", - "logIndex": "0x1", + "logIndex": "0x2", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000080000000000000000000000000000000000000000000000400200000000000000000000000000000000000000000200000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000008000000400000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x1b03d0897c0141c2741423c956d0e0e19ee075e4e1fa23249e58fdcad633f21e", + "transactionHash": "0x6cedcd8d6204f52e3494f8506674d0ca44c8792d5a616b379c270c178b673dc8", "transactionIndex": "0x1", - "blockHash": "0xcccca4d450248072417853097c3b0acd7be96aa9b34b4dc75791f0963809814a", - "blockNumber": "0x8b8e14", + "blockHash": "0x015d8fbecf5e455df9dec237108c2f2ffa36877c40244ae79d316f78d633e608", + "blockNumber": "0x8c1056", "gasUsed": "0x1fa157", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604" + "contractAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d" }, { "status": "0x1", - "cumulativeGasUsed": "0x55ef0", + "cumulativeGasUsed": "0x5fa9b", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0xbc7cd75a20ee27fd9adebab32041f755214dbc6bffa90cc0225b39da2e5c2d3b", - "0x00000000000000000000000033ef630510ba4d5e13cfc3a49ad35bef5c9c2604" + "0x00000000000000000000000050ec5c1c156cfa7e3007a0b0c97298e4f58a552d" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x0000000000000000000000000000000000000000000000000000000000000000", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x5", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e0", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4" ], "data": "0x", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x6", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x3", "removed": false }, { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb3847402498" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "logIndex": "0x7", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000400000000000000200804000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000002000001080000000000000000000000000000000000020000000000000000000800000000000000000040000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001000000000000000000000000000000000000000020000000", + "logsBloom": "0x00000000000000000000000000000100400000000000000000800000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000002000001000000000000000000000000000000100000024000000000000000000800000000000000000000000000000000400000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000400000000000000000000000000000020000000000020000000000000000000060000000000000000000000000000000020000000000000000001001000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "transactionIndex": "0x2", - "blockHash": "0x4fae2e40ce8d3858e9c78fa924523beb6984febab5794dd6a0bc496bed39390c", - "blockNumber": "0x8b8e16", + "transactionHash": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "transactionIndex": "0x1", + "blockHash": "0x18ef82ba2e2b0a07be36f603f3db91407d5e9d0a4bf8eba5bcaebf19f55c279c", + "blockNumber": "0x8c1058", "gasUsed": "0x32acb", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", "to": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563" + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e" } ], "libraries": [], "pending": [], "returns": { - "proxy": { + "impl": { "internal_type": "address", - "value": "0x8C1abf364Bf214E41221562693BD9Fb26D6Fa563" + "value": "0x50EC5c1C156cfA7e3007a0b0C97298E4f58a552d" }, - "impl": { + "proxy": { "internal_type": "address", - "value": "0x33EF630510Ba4d5e13Cfc3a49Ad35BEf5c9c2604" + "value": "0x99994b4522483DE17F31a5bC010c5901AdD3440E" } }, - "timestamp": 1786568797047, + "timestamp": 1787577343971, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json b/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json new file mode 100644 index 0000000..e72ed44 --- /dev/null +++ b/broadcast/04_BootstrapController.s.sol/30/run-1787578025992.json @@ -0,0 +1,434 @@ +{ + "transactions": [ + { + "hash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setFeeReceiver(address)", + "arguments": [ + "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x1257e", + "value": "0x0", + "input": "0xefdcd974000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b", + "nonce": "0x25", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x12379", + "value": "0x0", + "input": "0xd131f855d4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x26", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f855fa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b0180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x27", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f855fb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x28", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be", + "(true, 10)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x134b0", + "value": "0x0", + "input": "0xd131f85544224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x29", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setSurfacePolicy(bytes32,(bool,uint16))", + "arguments": [ + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2", + "(false, 0)" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0xb809", + "value": "0x0", + "input": "0xd131f855785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x2a", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "setAdmin(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x119a9", + "value": "0x0", + "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x2b", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "function": "transferOwnership(address)", + "arguments": [ + "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" + ], + "transaction": { + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x11c43", + "value": "0x0", + "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", + "nonce": "0x2c", + "chainId": "0x1e" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "status": "0x1", + "cumulativeGasUsed": "0x26d5b", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0xbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d", + "0x000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b" + ], + "data": "0x", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000400000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000004000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000400000008000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "gasUsed": "0xaf61", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x1465c", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040100000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "gasUsed": "0xc87a", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x47275", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "logIndex": "0x4", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000008000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x220d3", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x15d99", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000020000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "gasUsed": "0xc886", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x8dd6", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "gasUsed": "0x8dd6", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x5b534", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "logIndex": "0xd", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "gasUsed": "0xae3c", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + }, + { + "status": "0x1", + "cumulativeGasUsed": "0x537b0", + "logs": [ + { + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", + "topics": [ + "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", + "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", + "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" + ], + "data": "0x", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "logIndex": "0x5", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "type": "0x0", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "gasUsed": "0xb082", + "effectiveGasPrice": "0x18dbac0", + "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1787578025992, + "chain": 30, + "commit": "2c7afb4" +} \ No newline at end of file diff --git a/broadcast/04_BootstrapController.s.sol/30/run-latest.json b/broadcast/04_BootstrapController.s.sol/30/run-latest.json index 1d915fb..e72ed44 100644 --- a/broadcast/04_BootstrapController.s.sol/30/run-latest.json +++ b/broadcast/04_BootstrapController.s.sol/30/run-latest.json @@ -1,173 +1,173 @@ { "transactions": [ { - "hash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", + "hash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setFeeReceiver(address)", "arguments": [ - "0x2ba389B021fA4A5F50cc1758EFD23Ca066d0Be08" + "0xDDE75f75ff33Aa802f2316cCAe2bE77823fc6f9B" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x1257e", "value": "0x0", - "input": "0xefdcd9740000000000000000000000002ba389b021fa4a5f50cc1758efd23ca066d0be08", - "nonce": "0x9", + "input": "0xefdcd974000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b", + "nonce": "0x25", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", + "hash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x3d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb3", + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", - "gas": "0x134b0", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", + "gas": "0x12379", "value": "0x0", - "input": "0xd131f8553d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb30000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xa", + "input": "0xd131f855d4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x26", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", + "hash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x5c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f7", + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f8555c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f70000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xb", + "input": "0xd131f855fa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b0180000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x27", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", + "hash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x2e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d", + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f8552e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xc", + "input": "0xd131f855fb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb50000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x28", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", + "hash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0xdd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e0", + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be", "(true, 10)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x134b0", "value": "0x0", - "input": "0xd131f855dd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "nonce": "0xd", + "input": "0xd131f85544224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", + "nonce": "0x29", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", + "hash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setSurfacePolicy(bytes32,(bool,uint16))", "arguments": [ - "0x1605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f", + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2", "(false, 0)" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0xb809", "value": "0x0", - "input": "0xd131f8551605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "nonce": "0xe", + "input": "0xd131f855785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x2a", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", + "hash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "setAdmin(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x119a9", "value": "0x0", "input": "0x704b6c02000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0xf", + "nonce": "0x2b", "chainId": "0x1e" }, "additionalContracts": [], "isFixedGasLimit": false }, { - "hash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", + "hash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", "transactionType": "CALL", "contractName": null, - "contractAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "contractAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", "function": "transferOwnership(address)", "arguments": [ "0x924f5ad34698Fd20c90Fe5D5A8A0abd3b42dc711" ], "transaction": { "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "gas": "0x11c43", "value": "0x0", "input": "0xf2fde38b000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711", - "nonce": "0x10", + "nonce": "0x2c", "chainId": "0x1e" }, "additionalContracts": [], @@ -177,258 +177,258 @@ "receipts": [ { "status": "0x1", - "cumulativeGasUsed": "0xaf61", + "cumulativeGasUsed": "0x26d5b", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0xbdf37c276f641820b141429d245add2552b4118c0866e5a78638e3de5ef18d9d", - "0x0000000000000000000000002ba389b021fa4a5f50cc1758efd23ca066d0be08" + "0x000000000000000000000000dde75f75ff33aa802f2316ccae2be77823fc6f9b" ], "data": "0x", - "blockHash": "0xb02156cf1eec5d59b57281176caafaa59a19764e6fd6f145fc0a209a8e5382db", - "blockNumber": "0x8b8e30", - "transactionHash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", - "transactionIndex": "0x0", - "logIndex": "0x0", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000040000000000000000000000000000000000000000000000000000000400000000000000000000000000000000200000000000000000000000000000008000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000400000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000004000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000400000008000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x8413b99eb01cded2788d60bdd5aa45b7fc92ad572bcf4792adb754dc0309500f", - "transactionIndex": "0x0", - "blockHash": "0xb02156cf1eec5d59b57281176caafaa59a19764e6fd6f145fc0a209a8e5382db", - "blockNumber": "0x8b8e30", + "transactionHash": "0x6ccf80bd35562a99d4a74b6d3875df200e204fc63d0b71250eaf15d54a97c918", + "transactionIndex": "0x2", + "blockHash": "0xd0b4ed5164a5adb5756b2d9f3c08b69995ef34ac4ffd9466665996c0bdf6b2a1", + "blockNumber": "0x8c1065", "gasUsed": "0xaf61", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0xc886", + "cumulativeGasUsed": "0x1465c", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x3d0383a7986bf042db59f806aef31f95d28262f3280554c8541c299fa8e2ffb3" + "0xd4896528a9fba849e3d3db442dea05ef8f08c93e00cc760acac34c42a7dacffe" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x38af953639c4095a9ea745088ef6f012753e8af3d037f7a8e486262c3c662a94", - "blockNumber": "0x8b8e32", - "transactionHash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", - "transactionIndex": "0x0", - "logIndex": "0x0", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "logIndex": "0x1", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000800000000000000000000000000000200000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040100000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x6a5819eac0c56c338c3c2076c82cb70140d94cab3490b5d3ebdc753118a8e636", - "transactionIndex": "0x0", - "blockHash": "0x38af953639c4095a9ea745088ef6f012753e8af3d037f7a8e486262c3c662a94", - "blockNumber": "0x8b8e32", - "gasUsed": "0xc886", + "transactionHash": "0x09e70fa8e2280610218ac2c294e1272a2442919528f9486e243b33c111820af8", + "transactionIndex": "0x1", + "blockHash": "0x13c11ddaf2eab7b3e0c85fd99c58191981f4d05fd625f39a37fbf23a6409387f", + "blockNumber": "0x8c1066", + "gasUsed": "0xc87a", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x39438", + "cumulativeGasUsed": "0x47275", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x5c408ce1df6222b56e2084e292cdc734b880e9adbb4df2331d304431936967f7" + "0xfa502ea562018a194d7f66e337810fa8b882ec21f706f3b3c709a53fa126b018" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0xbb07fe842cdc5309eaeabb807ad66292612f5790a1cb812dfa4c85e149f55d9c", - "blockNumber": "0x8b8e34", - "transactionHash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", - "transactionIndex": "0x3", - "logIndex": "0x5", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "logIndex": "0x4", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000002000000000002020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000200000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000008000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0x3a9a4d52e633af19c23335f96d0c8d360384d53024e3b814dfa56d41a1f6e215", - "transactionIndex": "0x3", - "blockHash": "0xbb07fe842cdc5309eaeabb807ad66292612f5790a1cb812dfa4c85e149f55d9c", - "blockNumber": "0x8b8e34", + "transactionHash": "0xdecf11d7097b93598e7001e3802ceffb9a648ea55d4f70c60549b486bfcece9f", + "transactionIndex": "0x6", + "blockHash": "0x6d03ff0eb604c5c293fd3ad1a179a28c3b7dd68f4cf2ebf22a4ad24080d597f5", + "blockNumber": "0x8c1067", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x27fd6", + "cumulativeGasUsed": "0x220d3", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x2e0728b133b5607b74783aaf68be7f195d5ea71afa5bfbd21c35a6776773376d" + "0xfb3234ca0cf70fe9c90b73939f36a37fadcfdef4628afc42dd57d1f26dfd8fb5" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x8e5d97d4df0376ab600dc3050110efb48b1848809799b1d9bb98569866835fb5", - "blockNumber": "0x8b8e35", - "transactionHash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", - "transactionIndex": "0x2", - "logIndex": "0x4", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "logIndex": "0x2", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004100000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xe93a002c704f359a387dea9a72396e6091bf20db15f8dc81b59858f855834e15", - "transactionIndex": "0x2", - "blockHash": "0x8e5d97d4df0376ab600dc3050110efb48b1848809799b1d9bb98569866835fb5", - "blockNumber": "0x8b8e35", + "transactionHash": "0xd4f8966f0b09f5702ccc80c0524582c26ad6bff88c502da1739f1a7c21a25fe5", + "transactionIndex": "0x1", + "blockHash": "0x3b36ff724d8a10e9e121bdeaf1287a589b2b43f8808cb372fc1fa04ce8b93b48", + "blockNumber": "0x8c1068", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x44b6c", + "cumulativeGasUsed": "0x15d99", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0xdd1d6592d9143b113f128998b830887d87bf784969f0bdeda154f2a49ca302e0" + "0x44224716871939619faf861b30e39bac8861d4f76b5dd0468d31bf4b7dc684be" ], "data": "0x0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a", - "blockHash": "0x063c52e91329cb7ac0e1d60f7887ff6bb566850584fadcc21b2def7b2693620d", - "blockNumber": "0x8b8e36", - "transactionHash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", - "transactionIndex": "0x4", - "logIndex": "0x7", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000008000000000000000000002000000000000020000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000020000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xb704bca705c54f9eec867cc33b98713d3f4ed57ee2cad99406a0e9ed1422f6cd", - "transactionIndex": "0x4", - "blockHash": "0x063c52e91329cb7ac0e1d60f7887ff6bb566850584fadcc21b2def7b2693620d", - "blockNumber": "0x8b8e36", + "transactionHash": "0xdb4cc6384844a8f1ca79373058a4fbad0abf2d77cee8844b4bb50877340fb742", + "transactionIndex": "0x1", + "blockHash": "0xd703a5aa289aede5b180f1b05a63cf07f738b80c79300cb7e01adbb29a8172b9", + "blockNumber": "0x8c1069", "gasUsed": "0xc886", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x5e579", + "cumulativeGasUsed": "0x8dd6", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x9c2ed4beffca08031c728cc780c96150259726860364488ef2ea81abe4f028e4", - "0x1605494559c1ed30a971cad4be28853f4641084ed881e4694f503b415dd07f9f" + "0x785cea9856c907f8eb318fa26cc03e32cc9b61b22144c7a093eec9a60354a9b2" ], "data": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "blockHash": "0x27489b503a3a03b2fd408cab910dd88352d9c0b0f474b4d6da80a9f97ce36e28", - "blockNumber": "0x8b8e38", - "transactionHash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", - "transactionIndex": "0x5", - "logIndex": "0x6", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "logIndex": "0x0", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004002000000000000000000000000002000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000010000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000002000000000000020000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000", "type": "0x0", - "transactionHash": "0xd86788ee8b32203313beea2244fbdad6e55c84a31b5dbb099ff2dd619b9304d9", - "transactionIndex": "0x5", - "blockHash": "0x27489b503a3a03b2fd408cab910dd88352d9c0b0f474b4d6da80a9f97ce36e28", - "blockNumber": "0x8b8e38", + "transactionHash": "0x433ef05400f6b89f3d75ed90794a8195847af170bc7566764b5255167f5f4950", + "transactionIndex": "0x0", + "blockHash": "0x4bdbb62cb131d78995f554a09c0775af2e3b05b1005bbb1d4c8187108b1700e5", + "blockNumber": "0x8c106a", "gasUsed": "0x8dd6", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x7cd3c", + "cumulativeGasUsed": "0x5b534", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x8fe72c3e0020beb3234e76ae6676fa576fbfcae600af1c4fea44784cf0db329c", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x2d4627b50d9673d0ed4586f2745692cb04c971be4df882791f8c930e640c7ad0", - "blockNumber": "0x8b8e3a", - "transactionHash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", - "transactionIndex": "0x7", - "logIndex": "0xa", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "logIndex": "0xd", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000080400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000040000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xbe835db4545574e2d2b74618f93af172f5340ee29d7c89ff7ca619de5a7a06bb", - "transactionIndex": "0x7", - "blockHash": "0x2d4627b50d9673d0ed4586f2745692cb04c971be4df882791f8c930e640c7ad0", - "blockNumber": "0x8b8e3a", + "transactionHash": "0x840493989bd1035b6e888516b6582101c0c883f92a574624dbd183fa61a938b2", + "transactionIndex": "0x1", + "blockHash": "0x763b833bb6853456015d4e39caafd177982d7aa0387c3d366b135c0bd63cfdb3", + "blockNumber": "0x8c106b", "gasUsed": "0xae3c", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null }, { "status": "0x1", - "cumulativeGasUsed": "0x4ab29", + "cumulativeGasUsed": "0x537b0", "logs": [ { - "address": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "address": "0x99994b4522483de17f31a5bc010c5901add3440e", "topics": [ "0x38d16b8cac22d99fc7c124b9cd0de2d3fa1faef420bfe791d8c362d765e22700", "0x000000000000000000000000163463b7ddbce853832037a059f5c5e6606bf9c4", "0x000000000000000000000000924f5ad34698fd20c90fe5d5a8a0abd3b42dc711" ], "data": "0x", - "blockHash": "0x51f098c68d03f1103195fde0859816d0bbf8cad153aa1a29de455f5303b0fe15", - "blockNumber": "0x8b8e3b", - "transactionHash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", - "transactionIndex": "0x2", - "logIndex": "0x6", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "logIndex": "0x5", "removed": false } ], - "logsBloom": "0x00000000000000000000000000000000000000000000000200004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000100000000000000000000000000040000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000020004000002000000000000000000000020000100000000000000000000000000000000000000000000001000000000000000000002000000000000000000000000000", "type": "0x0", - "transactionHash": "0xf92d489da031439c20346bc8ff2752c088aec18866543b693f1ebd939906a66b", - "transactionIndex": "0x2", - "blockHash": "0x51f098c68d03f1103195fde0859816d0bbf8cad153aa1a29de455f5303b0fe15", - "blockNumber": "0x8b8e3b", + "transactionHash": "0x85485a9a820a1ff5341c3f7a6526bd25ebaa8abb1b30b93abf94a0b30c6b23e5", + "transactionIndex": "0x3", + "blockHash": "0xda1d9a0619e394f9fdaa1354858d609ab1de9dc5ec1afe270d5ba16ac5291c6f", + "blockNumber": "0x8c106c", "gasUsed": "0xb082", "effectiveGasPrice": "0x18dbac0", "from": "0x163463b7ddbce853832037a059f5c5e6606bf9c4", - "to": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", + "to": "0x99994b4522483de17f31a5bc010c5901add3440e", "contractAddress": null } ], "libraries": [], "pending": [], "returns": {}, - "timestamp": 1786570159460, + "timestamp": 1787578025992, "chain": 30, - "commit": "881c74d" + "commit": "2c7afb4" } \ No newline at end of file diff --git a/deployments/30/ExitFeeController.json b/deployments/30/ExitFeeController.json index f3c76a2..c8c238a 100644 --- a/deployments/30/ExitFeeController.json +++ b/deployments/30/ExitFeeController.json @@ -1,13 +1,13 @@ { "contractName": "ExitFeeController", "chainId": 30, - "proxyAddress": "0x8c1abf364bf214e41221562693bd9fb26d6fa563", - "implAddress": "0x33ef630510ba4d5e13cfc3a49ad35bef5c9c2604", - "implBytecodeHash": "0x694d8028a8ec9c45c5bb96a73e43d8d06fe6f0c064c544c8e2fe21932e16f56e", - "deploymentBlock": 9145878, - "deploymentTx": "0x690cff6bcc8abfb38b1a0b75cc6f7eba1f37dacc29705f4cfbfcd9b6abb597d7", - "timestamp": 1786568797047, - "gitSha": "881c74d", + "proxyAddress": "0x99994b4522483de17f31a5bc010c5901add3440e", + "implAddress": "0x50ec5c1c156cfa7e3007a0b0c97298e4f58a552d", + "implBytecodeHash": "0x114d739562bd85d57815ddee02c575a110258cc9750e1cc56e35195717ca039c", + "deploymentBlock": 9179224, + "deploymentTx": "0x176fc862fdc57945813c21d69430834fc081063481bc311a7054cce7232f0986", + "timestamp": 1787577343971, + "gitSha": "2c7afb4", "abi": [ { "type": "constructor", @@ -985,7 +985,7 @@ "storageLayout": { "storage": [ { - "astId": 36764, + "astId": 41142, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_initialized", "offset": 0, @@ -993,7 +993,7 @@ "type": "t_uint8" }, { - "astId": 36767, + "astId": 41145, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_initializing", "offset": 1, @@ -1001,7 +1001,7 @@ "type": "t_bool" }, { - "astId": 36746, + "astId": 41124, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1009,7 +1009,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37062, + "astId": 41440, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1017,7 +1017,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37528, + "astId": 41906, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1025,7 +1025,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 36270, + "astId": 40648, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_owner", "offset": 0, @@ -1033,7 +1033,7 @@ "type": "t_address" }, { - "astId": 36390, + "astId": 40768, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1041,7 +1041,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 36163, + "astId": 40541, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_pendingOwner", "offset": 0, @@ -1049,7 +1049,7 @@ "type": "t_address" }, { - "astId": 36257, + "astId": 40635, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1057,7 +1057,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 39780, + "astId": 46123, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "exitFeeEnabled", "offset": 0, @@ -1065,7 +1065,7 @@ "type": "t_bool" }, { - "astId": 39783, + "astId": 46126, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "feeReceiver", "offset": 1, @@ -1073,47 +1073,47 @@ "type": "t_address" }, { - "astId": 39789, + "astId": 46132, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_surfacePolicy", "offset": 0, "slot": "252", - "type": "t_mapping(t_bytes32,t_struct(RatePolicy)41221_storage)" + "type": "t_mapping(t_bytes32,t_struct(RatePolicy)47564_storage)" }, { - "astId": 39797, + "astId": 46140, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_subProductPolicy", "offset": 0, "slot": "253", - "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))" + "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))" }, { - "astId": 39805, + "astId": 46148, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_actorPolicy", "offset": 0, "slot": "254", - "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))" + "type": "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))" }, { - "astId": 39811, + "astId": 46154, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_subProductKeys", "offset": 0, "slot": "255", - "type": "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)" + "type": "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)" }, { - "astId": 39816, + "astId": 46159, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_actorKeys", "offset": 0, "slot": "256", - "type": "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)" + "type": "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)" }, { - "astId": 39819, + "astId": 46162, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "admin", "offset": 0, @@ -1121,7 +1121,7 @@ "type": "t_address" }, { - "astId": 39823, + "astId": 46166, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "__gap", "offset": 0, @@ -1169,33 +1169,33 @@ "label": "bytes32", "numberOfBytes": "32" }, - "t_mapping(t_address,t_struct(RatePolicy)41221_storage)": { + "t_mapping(t_address,t_struct(RatePolicy)47564_storage)": { "encoding": "mapping", "key": "t_address", "label": "mapping(address => struct IExitFeeController.RatePolicy)", "numberOfBytes": "32", - "value": "t_struct(RatePolicy)41221_storage" + "value": "t_struct(RatePolicy)47564_storage" }, - "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)41221_storage))": { + "t_mapping(t_bytes32,t_mapping(t_address,t_struct(RatePolicy)47564_storage))": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => mapping(address => struct IExitFeeController.RatePolicy))", "numberOfBytes": "32", - "value": "t_mapping(t_address,t_struct(RatePolicy)41221_storage)" + "value": "t_mapping(t_address,t_struct(RatePolicy)47564_storage)" }, - "t_mapping(t_bytes32,t_struct(AddressSet)39329_storage)": { + "t_mapping(t_bytes32,t_struct(AddressSet)44349_storage)": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => struct EnumerableSet.AddressSet)", "numberOfBytes": "32", - "value": "t_struct(AddressSet)39329_storage" + "value": "t_struct(AddressSet)44349_storage" }, - "t_mapping(t_bytes32,t_struct(RatePolicy)41221_storage)": { + "t_mapping(t_bytes32,t_struct(RatePolicy)47564_storage)": { "encoding": "mapping", "key": "t_bytes32", "label": "mapping(bytes32 => struct IExitFeeController.RatePolicy)", "numberOfBytes": "32", - "value": "t_struct(RatePolicy)41221_storage" + "value": "t_struct(RatePolicy)47564_storage" }, "t_mapping(t_bytes32,t_uint256)": { "encoding": "mapping", @@ -1204,28 +1204,28 @@ "numberOfBytes": "32", "value": "t_uint256" }, - "t_struct(AddressSet)39329_storage": { + "t_struct(AddressSet)44349_storage": { "encoding": "inplace", "label": "struct EnumerableSet.AddressSet", "numberOfBytes": "64", "members": [ { - "astId": 39328, + "astId": 44348, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_inner", "offset": 0, "slot": "0", - "type": "t_struct(Set)39014_storage" + "type": "t_struct(Set)44034_storage" } ] }, - "t_struct(RatePolicy)41221_storage": { + "t_struct(RatePolicy)47564_storage": { "encoding": "inplace", "label": "struct IExitFeeController.RatePolicy", "numberOfBytes": "32", "members": [ { - "astId": 41218, + "astId": 47561, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "active", "offset": 0, @@ -1233,7 +1233,7 @@ "type": "t_bool" }, { - "astId": 41220, + "astId": 47563, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "rateBps", "offset": 1, @@ -1242,13 +1242,13 @@ } ] }, - "t_struct(Set)39014_storage": { + "t_struct(Set)44034_storage": { "encoding": "inplace", "label": "struct EnumerableSet.Set", "numberOfBytes": "64", "members": [ { - "astId": 39009, + "astId": 44029, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_values", "offset": 0, @@ -1256,7 +1256,7 @@ "type": "t_array(t_bytes32)dyn_storage" }, { - "astId": 39013, + "astId": 44033, "contract": "src/ExitFeeController.sol:ExitFeeController", "label": "_indexes", "offset": 0, diff --git a/deployments/30/ExitFeeVault.json b/deployments/30/ExitFeeVault.json index 51c629f..8a72b16 100644 --- a/deployments/30/ExitFeeVault.json +++ b/deployments/30/ExitFeeVault.json @@ -1,13 +1,13 @@ { "contractName": "ExitFeeVault", "chainId": 30, - "proxyAddress": "0x2ba389b021fa4a5f50cc1758efd23ca066d0be08", - "implAddress": "0xab3761d0800c4310414e75fd8b545ce094c20e26", - "implBytecodeHash": "0xcd2b99395b1146da04dd1cdcc66973cfbb232200a7d49ff68128724d7c217aa0", - "deploymentBlock": 9145756, - "deploymentTx": "0xfe80d4be283d43898fced68ed701d4d02d4bcf40d1f1f9f08144ce3889efdf78", - "timestamp": 1786565025004, - "gitSha": "881c74d", + "proxyAddress": "0xdde75f75ff33aa802f2316ccae2be77823fc6f9b", + "implAddress": "0x8f977f4c9dccce1a0306a34944c0460e4445deb4", + "implBytecodeHash": "0x270528aa2181e4eb17c3ee850bdab3a5f7a13b68aebf2238c1cdfa58675a0c69", + "deploymentBlock": 9179117, + "deploymentTx": "0x2ae9f18519bca0d81fa4695a3f63ded3932f926959c9fc661df6b5aef5b28818", + "timestamp": 1787574212713, + "gitSha": "2c7afb4", "abi": [ { "type": "constructor", @@ -474,7 +474,7 @@ "storageLayout": { "storage": [ { - "astId": 36764, + "astId": 41142, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_initialized", "offset": 0, @@ -482,7 +482,7 @@ "type": "t_uint8" }, { - "astId": 36767, + "astId": 41145, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_initializing", "offset": 1, @@ -490,7 +490,7 @@ "type": "t_bool" }, { - "astId": 36746, + "astId": 41124, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -498,7 +498,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37062, + "astId": 41440, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -506,7 +506,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 37528, + "astId": 41906, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -514,7 +514,7 @@ "type": "t_array(t_uint256)50_storage" }, { - "astId": 36270, + "astId": 40648, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_owner", "offset": 0, @@ -522,7 +522,7 @@ "type": "t_address" }, { - "astId": 36390, + "astId": 40768, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -530,7 +530,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 36163, + "astId": 40541, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_pendingOwner", "offset": 0, @@ -538,7 +538,7 @@ "type": "t_address" }, { - "astId": 36257, + "astId": 40635, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -546,7 +546,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 37078, + "astId": 41456, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "_status", "offset": 0, @@ -554,7 +554,7 @@ "type": "t_uint256" }, { - "astId": 37147, + "astId": 41525, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, @@ -562,7 +562,7 @@ "type": "t_array(t_uint256)49_storage" }, { - "astId": 40842, + "astId": 47185, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "defaultRecipient", "offset": 0, @@ -570,7 +570,7 @@ "type": "t_address_payable" }, { - "astId": 40845, + "astId": 47188, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "admin", "offset": 0, @@ -578,7 +578,7 @@ "type": "t_address" }, { - "astId": 40849, + "astId": 47192, "contract": "src/ExitFeeVault.sol:ExitFeeVault", "label": "__gap", "offset": 0, diff --git a/tools/verify-proxy-fullmatch.sh b/tools/verify-proxy-fullmatch.sh new file mode 100755 index 0000000..74f6ccf --- /dev/null +++ b/tools/verify-proxy-fullmatch.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Attempt a FULL-match verification of an already-verified ERC1967Proxy. +# +# Blockscout matches a standard OpenZeppelin proxy from its own bytecode +# database, which yields a PARTIAL match: the runtime code matches but the +# metadata hash comes from someone else's compilation, not ours. This asks the +# explorer to re-verify against our exact build so the match becomes full. +# +# Read-only with respect to the chain: verification submits source, never a +# transaction. Safe to re-run; an "already verified" reply is not an error. +# +# tools/verify-proxy-fullmatch.sh +set -uo pipefail + +PROXY=${1:?proxy address required} +IMPL=${2:?implementation address required} +OWNER=${3:?initial owner (the address baked into initialize) required} +SRC="lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol:ERC1967Proxy" +VERIFIER_URL=${BLOCKSCOUT_URL:-https://rootstock.blockscout.com/api} + +INIT=$(cast calldata "initialize(address)" "$OWNER") +ARGS=$(cast abi-encode "constructor(address,bytes)" "$IMPL" "$INIT") + +echo "proxy: $PROXY" +echo "impl: $IMPL" +echo "owner: $OWNER" +echo "args: ${ARGS:0:42}..." +echo + +forge verify-contract "$PROXY" "$SRC" \ + --chain 30 \ + --verifier blockscout \ + --verifier-url "$VERIFIER_URL" \ + --constructor-args "$ARGS" \ + --watch