diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index 35be4b97..d2390995 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -31,6 +31,7 @@ on: - 20260722-swap-remaining-vault-authorisers - 20260723-provision-additional-service-signer - 20260729-migrate-governance-to-timelock + - 20260810-revoke-fireblocks-service-signer network: description: 'Network to author against (default: base)' required: true diff --git a/script/20260729-migrate-governance-to-timelock.s.sol b/script/20260729-migrate-governance-to-timelock.s.sol index 629f21e9..cdadea79 100644 --- a/script/20260729-migrate-governance-to-timelock.s.sol +++ b/script/20260729-migrate-governance-to-timelock.s.sol @@ -114,13 +114,6 @@ struct MigrationTargets { bytes32[] renounceRoles; } -/// @notice A field of the artifact under signer-side verification does not -/// match the bundle re-derived from CURRENT live chain state. The artifact -/// is stale (state moved since authoring), tampered, or authored for a -/// different chain — either way it must not be signed. -/// @param field The first mismatching field. -error MigrationVerifyMismatch(string field); - /// @notice The end-to-end governance-loop proof did not leave the scheduled /// operation in the `Done` state — the schedule → delay → execute path a /// future admin action must take does not work against the post-migration @@ -384,24 +377,15 @@ contract MigrateGovernanceToTimelock is Script { } /// @notice Compare a parsed artifact against the live-derived bundle - /// field by field, then log the canonical MultiSend `SafeTxHash` at the - /// live nonce for the Safe-UI cross-check. Split from `verify` for the - /// same legacy-codegen stack-limit reason as `_assertPostState`. + /// via the shared `LibSafeOps.assertParsedTxsMatch`, then log the + /// canonical MultiSend `SafeTxHash` at the live nonce for the Safe-UI + /// cross-check. Split from `verify` for the same legacy-codegen + /// stack-limit reason as `_assertPostState`. /// @param safe The chain's token-owner Safe. /// @param expected The bundle derived from live state. /// @param jsonPath Filesystem path to the artifact under verification. function _verifyArtifact(IGnosisSafe safe, SafeTx[] memory expected, string calldata jsonPath) internal view { - (uint256 parsedChainId, address parsedFirstTarget, SafeTx[] memory parsed) = - LibSafeOps.parseTxBuilderJson(jsonPath); - if (parsedChainId != block.chainid) revert MigrationVerifyMismatch("chainId"); - if (parsed.length != expected.length) revert MigrationVerifyMismatch("txCount"); - if (parsedFirstTarget != expected[0].to) revert MigrationVerifyMismatch("firstTarget"); - for (uint256 i = 0; i < expected.length; i++) { - if (parsed[i].to != expected[i].to) revert MigrationVerifyMismatch("to"); - if (parsed[i].value != expected[i].value) revert MigrationVerifyMismatch("value"); - if (parsed[i].operation != expected[i].operation) revert MigrationVerifyMismatch("operation"); - if (keccak256(parsed[i].data) != keccak256(expected[i].data)) revert MigrationVerifyMismatch("data"); - } + LibSafeOps.assertParsedTxsMatch(expected, jsonPath); uint256 nonce = safe.nonce(); console2.log("Artifact verified against live state."); diff --git a/script/20260810-revoke-fireblocks-service-signer.s.sol b/script/20260810-revoke-fireblocks-service-signer.s.sol new file mode 100644 index 00000000..910c991c --- /dev/null +++ b/script/20260810-revoke-fireblocks-service-signer.s.sol @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; + +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; + +/// @notice The retired Fireblocks service signer no longer holds any of its +/// canonical grants on this chain — the revocation has executed and there +/// is nothing left to author. +error FireblocksSignerAlreadyRevoked(); + +/// @notice The active chain has no usable V4 authoriser (unsupported chain, +/// unhydrated pin, no code, or codehash drift). +/// @param authoriser The authoriser address inspected (`address(0)` when +/// the chain's pin is unhydrated). +error AuthoriserNotReadyForRevocation(address authoriser); + +/// @notice No V4 authoriser pin exists for the active chain at all. +/// @param chainId The unsupported chain id. +error UnsupportedChainForRevocation(uint256 chainId); + +/// @notice The Safe does not hold the `_ADMIN` role that admins one of the +/// revoked roles — the revoke would revert inside the Safe tx. +/// @param adminRole The missing admin role. +error SafeMissingRoleAdmin(bytes32 adminRole); + +/// @title RevokeFireblocksServiceSigner +/// @notice **PENDING.** Authors the Safe bundle that revokes the RETIRED +/// Fireblocks-custodied service signer +/// (`LibAuthoriserInvariants.GRANTEE_SERVICE_1C66`) from the ACTIVE chain's +/// V4 authoriser: one `revokeRole` per canonical grant the signer still +/// holds (`DEPOSIT` / `WITHDRAW` / `CERTIFY` — the map's `1C66` rows). The +/// signer's duties moved to the additional service signer +/// (`GRANTEE_SERVICE_3D0C`, provisioned by the `20260723` bundle on every +/// live chain), so this is the second half of the signer rotation: the +/// Fireblocks wallet loses its authoriser roles and the map converges on +/// the replacement signer. Dispatch via `Actions → run-script` with +/// `script = 20260810-revoke-fireblocks-service-signer`, `sig = run()`, +/// and the target `network`; one dispatch + Safe signing per chain +/// carrying a live authoriser — `base`, `ethereum` and `hyperevm`, all +/// three of which hold the signer's three canonical pairs today. +/// +/// SAFETY — the drift guard doubles as a rotation gate: pre-flight +/// requires every NON-retired row of +/// `LibAuthoriserInvariants.expectedGrants()` to hold, which includes all +/// three of the replacement signer's rows. A chain where the replacement +/// signer is not fully provisioned therefore refuses to author the +/// revoke, so the service can never be left without an active signer. +/// +/// The canonical map still carries the retired signer's rows — it pins +/// what production IS, and the signer holds its grants until the Safes +/// sign. Executing this bundle on a chain turns that chain's `1C66` rows +/// red (`ExpectedGrantMissing`) in every live invariant — the cross-chain +/// parity authoriser leg, the multichain production-state bundle, the +/// per-chain prod pins — forcing the post-execution pin PR that removes +/// the rows from the map and pins their ABSENCE as the new negative +/// invariant. The prod pin for this script +/// (`20260810-revoke-fireblocks-service-signer.prod.t.sol`) works the +/// same way in the opposite direction: it asserts `run()` authors a +/// full three-pair bundle on each chain, so execution flips it to +/// `FireblocksSignerAlreadyRevoked` and it retires in the same pin PR. +/// +/// SELF-SCOPING: only the pairs the signer still holds are authored +/// (partial execution recovers by re-dispatch); a fully revoked chain +/// refuses to author an empty bundle. +/// +/// @dev Flow: pre-flight (Safe state via the shared chain-aware entry +/// point; authoriser pinned + deployed + pinned codehash; every +/// non-retired row of the ceremony grant map intact; Safe holds the +/// `_ADMIN` role for every revoked role) -> build one `revokeRole` tx per +/// still-held pair -> SafeTxHash against the live nonce -> simulate as the +/// Safe -> post-state (the retired signer holds nothing; every other row +/// untouched; Safe identity + threshold unchanged) -> artifact to +/// `out/20260810-revoke-fireblocks-service-signer-.json` -> n+1 walk +/// proving the revocation is REVERSIBLE (re-grant sits under the same +/// `_ADMIN` the Safe holds): re-grant the first role under the live +/// threshold, then revoke again so the simulated fork ends revoked. +contract RevokeFireblocksServiceSigner is Script { + /// @notice The retired Fireblocks service signer being revoked — the + /// canonical pin from the invariant lib. + address internal constant RETIRED_SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_1C66; + + /// @notice Human-readable name embedded in the emitted Tx Builder JSON's + /// `meta.name`. Visible to signers in the Safe Tx Builder UI. + string internal constant BUNDLE_NAME = "ST0x revoke: retired Fireblocks service signer on the V4 authoriser"; + + /// @notice Output path (relative to the project root) for the Tx Builder + /// JSON artifact. Chain-suffixed — unlike the single-chain-at-a-time + /// predecessors, this operation dispatches against three chains in one + /// operational window, and a shared path would let one chain's bundle + /// silently overwrite another's (in a signer's download folder as + /// readily as in the test suite's shared `out/`). + /// Virtual so a test that authors DIFFERENT bundle content on the same + /// chain (the partial-re-dispatch fixture) can write to its own path: + /// forge runs tests in parallel and same-chain tests otherwise share + /// this file, which is benign only while every writer produces + /// identical bytes. + /// @return path The artifact path for the ACTIVE chain. + function artifactPath() internal view virtual returns (string memory path) { + path = string.concat("out/20260810-revoke-fireblocks-service-signer-", vm.toString(block.chainid), ".json"); + } + + /// @notice The `_ADMIN` role that admins `role` under the authoriser's + /// hierarchy: `` is admined by `_ADMIN`. + /// @dev Hardcoding the mapping is safe rather than lucky. The hierarchy + /// is written by `_setRoleAdmin` inside the authoriser's `initialize` + /// and nowhere else — OpenZeppelin's `_setRoleAdmin` is `internal` and + /// `AccessControlUpgradeable` exposes no external setter — so a given + /// implementation's mapping is fixed at initialisation and immutable + /// thereafter. `activeChainAuthoriser()` asserts the clone's runtime + /// codehash equals the pinned EIP-1167 runtime embedding the audited + /// 0.1.1 implementation, which is therefore proof of WHICH mapping the + /// clone carries. A live `getRoleAdmin` read would be re-deriving what + /// the codehash pin already establishes. + /// @param role The action role. + /// @return adminRole The role's admin role. + function roleAdminOf(bytes32 role) internal pure returns (bytes32 adminRole) { + if (role == keccak256("DEPOSIT")) return keccak256("DEPOSIT_ADMIN"); + if (role == keccak256("WITHDRAW")) return keccak256("WITHDRAW_ADMIN"); + if (role == keccak256("CERTIFY")) return keccak256("CERTIFY_ADMIN"); + revert("RevokeFireblocksServiceSigner: no admin mapping for role"); + } + + /// @notice The active chain's hydrated V4 authoriser, asserted deployed + /// with the shared EIP-1167 codehash. + /// @return authoriser The validated authoriser address. + function activeChainAuthoriser() internal view returns (address authoriser) { + if (block.chainid == LibSafeInvariants.BASE_CHAIN_ID) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + } else if (block.chainid == LibSafeInvariants.ETHEREUM_CHAIN_ID) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM; + } else if (block.chainid == LibSafeInvariants.HYPEREVM_CHAIN_ID) { + authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM; + } else { + revert UnsupportedChainForRevocation(block.chainid); + } + if ( + authoriser == address(0) || authoriser.code.length == 0 + || authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH + ) { + revert AuthoriserNotReadyForRevocation(authoriser); + } + } + + /// @notice Pre-flight the authoriser's grant map and self-scope the + /// bundle: the retired signer's canonical pairs, plus one `revokeRole` + /// transaction per pair the signer still holds. + /// + /// Reverts when the map has drifted (any non-retired row missing — which + /// includes every replacement-signer row, so an under-provisioned chain + /// can never author the revoke), when the Safe does not admin a revoked + /// role, or when no pair holds any longer + /// (`FireblocksSignerAlreadyRevoked` — nothing left to author). + /// + /// @dev Every chain-specific input arrives as an argument rather than + /// being read from `block.chainid`, so the selection is exercisable + /// against ANY authoriser carrying the canonical map — a locally + /// cloned one in the unit suite as readily as a pinned production + /// clone on a fork. + /// @param authoriser The authoriser whose live role state is read. + /// @param safeAddr The chain's token-owner Safe: fills the Safe + /// grantee slots of the canonical map and holds every `_ADMIN` role. + /// @return retired The retired signer's canonical pairs, in map order. + /// @return txs One `revokeRole` tx per pair the signer still holds, + /// in map order. + function authorBundle(address authoriser, address safeAddr) + internal + view + returns (RoleGrant[] memory retired, SafeTx[] memory txs) + { + IAccessControl acl = IAccessControl(authoriser); + + // Split the canonical map: the retired signer's rows are the work + // items; every OTHER row must already hold — the revoke must never + // be authored on an authoriser whose configuration has drifted, + // and the replacement signer's rows being part of "every other + // row" is the rotation gate described in the contract NatSpec. + RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safeAddr); + uint256 pairCount = 0; + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == RETIRED_SIGNER) { + pairCount++; + continue; + } + require( + acl.hasRole(all[i].role, all[i].grantee), + "RevokeFireblocksServiceSigner: authoriser grant map has drifted" + ); + } + retired = new RoleGrant[](pairCount); + uint256 p = 0; + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == RETIRED_SIGNER) { + retired[p] = all[i]; + p++; + } + } + + // The Safe holds the `_ADMIN` role for every revoked role. + for (uint256 i = 0; i < retired.length; i++) { + bytes32 adminRole = roleAdminOf(retired[i].role); + if (!acl.hasRole(adminRole, safeAddr)) { + revert SafeMissingRoleAdmin(adminRole); + } + } + + // Self-scope to the pairs still held. + bool[] memory held = new bool[](retired.length); + uint256 count = 0; + for (uint256 i = 0; i < retired.length; i++) { + if (acl.hasRole(retired[i].role, retired[i].grantee)) { + held[i] = true; + count++; + } + } + if (count == 0) { + revert FireblocksSignerAlreadyRevoked(); + } + + txs = new SafeTx[](count); + uint256 t = 0; + for (uint256 i = 0; i < retired.length; i++) { + if (!held[i]) continue; + txs[t] = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.revokeRole, (retired[i].role, retired[i].grantee)), + operation: 0 + }); + t++; + } + } + + /// @notice Assert the post-revocation grant state on the active fork: + /// the retired signer holds NONE of its canonical roles (and no + /// `DEFAULT_ADMIN_ROLE`), while every other row of the canonical map + /// still holds — the revoke touched exactly the retired signer's rows. + /// @dev The map-wide `assertExpectedGrants` cannot be used here: the + /// canonical map still carries the retired signer's rows (it pins what + /// production IS until the bundle executes), so the post-state is + /// asserted row-by-row with the retired rows negated. The pin PR that + /// lands after execution moves this negation into the invariant lib. + /// @param acl The authoriser's access-control surface. + /// @param safeAddr The chain's token-owner Safe filling the map's Safe + /// grantee slots. + function assertPostRevocationState(IAccessControl acl, address safeAddr) internal view { + RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safeAddr); + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == RETIRED_SIGNER) { + require( + !acl.hasRole(all[i].role, all[i].grantee), + "RevokeFireblocksServiceSigner: retired signer still holds a canonical role" + ); + } else { + require( + acl.hasRole(all[i].role, all[i].grantee), + "RevokeFireblocksServiceSigner: revocation disturbed an unrelated grant" + ); + } + } + require( + !acl.hasRole(LibAuthoriserInvariants.DEFAULT_ADMIN_ROLE, RETIRED_SIGNER), + "RevokeFireblocksServiceSigner: retired signer holds DEFAULT_ADMIN_ROLE" + ); + } + + /// @notice Author the revocation bundle for the active chain: see the + /// contract-level flow. Does not broadcast — execution happens via the + /// Safe UI using the emitted artifact. + function run() external { + // --- Pre-flight --------------------------------------------------- + + address safeAddr = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + IGnosisSafe safe = IGnosisSafe(safeAddr); + + address authoriser = activeChainAuthoriser(); + IAccessControl acl = IAccessControl(authoriser); + + // --- Build the bundle ---------------------------------------------- + + (RoleGrant[] memory retired, SafeTx[] memory txs) = authorBundle(authoriser, safeAddr); + + // The whole bundle executes as ONE MultiSend at this nonce, so the + // canonical MultiSend `SafeTxHash` is the hash the Safe UI shows + // signers — a per-inner-tx hash never appears there once the Tx + // Builder batches the import. + uint256 nonce = safe.nonce(); + bytes32 bundleSafeTxHash = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); + + // --- Simulate ----------------------------------------------------- + + for (uint256 i = 0; i < txs.length; i++) { + LibSafeOps.simulateExternalCall(safe, txs[i].to, txs[i].data); + } + + // --- Post-state --------------------------------------------------- + + // The retired signer holds nothing; every other row of the canonical + // map is untouched; the Safe identity + threshold are unchanged. + assertPostRevocationState(acl, safeAddr); + LibSafeInvariants.assertImmutableInvariants(safe); + LibSafeInvariants.assertThreshold(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + + // --- Artifact ----------------------------------------------------- + + string memory json = LibSafeOps.emitTxBuilderJson(safeAddr, block.chainid, BUNDLE_NAME, txs); + vm.writeFile(artifactPath(), json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("Bundle MultiSend SafeTxHash:", vm.toString(bundleSafeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Bundle item count:", txs.length); + console2.log("Chain:", block.chainid); + console2.log("Authoriser:", authoriser); + console2.log("Retired signer:", RETIRED_SIGNER); + + // --- n+1 reversal proof -------------------------------------------- + + // The revocation is fully reversible (re-grant sits under the same + // `_ADMIN` the Safe holds). Prove the re-grant clears the live + // threshold on the first role, then revoke again so the fork ends + // revoked. + LibSafeOps.simulateNPlus1( + safe, + authoriser, + abi.encodeCall(IAccessControl.grantRole, (retired[0].role, retired[0].grantee)), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + require( + acl.hasRole(retired[0].role, retired[0].grantee), + "RevokeFireblocksServiceSigner: n+1 re-grant did not restore the role" + ); + LibSafeOps.simulateExternalCall( + safe, authoriser, abi.encodeCall(IAccessControl.revokeRole, (retired[0].role, retired[0].grantee)) + ); + console2.log("n+1 reversal check passed: the Safe can re-grant (and re-revoke) under the live threshold"); + } + + /// @notice Signer-side integrity check for a CI-authored revocation + /// artifact, run LOCALLY against a live fork before signing: re-runs + /// the pre-flight and grant-map drift guard, re-derives the revoke + /// bundle from CURRENT live chain state, asserts the artifact at + /// `jsonPath` matches it byte-exactly (shared + /// `LibSafeOps.assertParsedTxsMatch`), and logs the canonical MultiSend + /// `SafeTxHash` at the live nonce for the Safe-UI cross-check. Any + /// drift between authoring and signing — a nonce bump, a pair already + /// revoked, a stale or tampered artifact — surfaces as a typed + /// mismatch before anyone signs. Deliberately NOT in the run-script + /// dispatcher: it takes a local path and runs on the signer's machine. + /// @param jsonPath Filesystem path to the downloaded Tx Builder JSON. + function verify(string calldata jsonPath) external view { + address safeAddr = LibSafeInvariants.assertActiveChainTokenOwnerSafe(block.chainid); + IGnosisSafe safe = IGnosisSafe(safeAddr); + address authoriser = activeChainAuthoriser(); + + (, SafeTx[] memory expected) = authorBundle(authoriser, safeAddr); + LibSafeOps.assertParsedTxsMatch(expected, jsonPath); + + uint256 nonce = safe.nonce(); + console2.log("Artifact verified against live state."); + console2.log( + "Bundle MultiSend SafeTxHash:", vm.toString(LibSafeOps.computeMultiSendSafeTxHash(safe, expected, nonce)) + ); + console2.log("Nonce:", nonce); + } +} diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index ea6a74cf..7a948b50 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -95,10 +95,8 @@ library LibAuthoriserInvariants { /// constant for call-site clarity. address internal constant GRANTEE_TOKEN_OWNER_SAFE = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; - /// @notice External service EOA granted `DEPOSIT` (block 41797262), - /// `WITHDRAW` (block 41797281) and `CERTIFY` (block 41797297) shortly - /// after the first service was provisioned. EOA, active service signer. - /// @dev TODO: confirm identity and rename. + /// @notice The original service EOA — Fireblocks-custodied — holding + /// `DEPOSIT`, `WITHDRAW` and `CERTIFY` on each chain's authoriser. /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; diff --git a/src/lib/LibSafeOps.sol b/src/lib/LibSafeOps.sol index 614bae02..9cfea1e5 100644 --- a/src/lib/LibSafeOps.sol +++ b/src/lib/LibSafeOps.sol @@ -33,6 +33,13 @@ error TxBuilderJsonNoTransactions(); /// @param operation The unsupported operation value. error TxBuilderJsonUnsupportedOperation(uint256 index, uint8 operation); +/// @notice A field of a Tx Builder artifact under signer-side verification +/// does not match the bundle re-derived from CURRENT live chain state. The +/// artifact is stale (state moved since authoring), tampered, or authored +/// for a different chain — either way it must not be signed. +/// @param field The first mismatching field. +error TxBuilderArtifactMismatch(string field); + /// @notice A single Safe-Tx Builder transaction in canonical form. Mirrors /// the per-transaction shape of the Safe Tx Builder JSON. /// @param to The destination address of the inner transaction. @@ -340,6 +347,34 @@ library LibSafeOps { safeAddr = txs[0].to; } + /// @notice Signer-side integrity comparison shared by every script's + /// `verify(string)`: parse the Tx Builder artifact at `jsonPath` and + /// assert it matches `expected` — the bundle the caller re-derived from + /// CURRENT live chain state — byte-exactly. Checks the artifact's chain + /// id against the active chain, the transaction count, the first + /// target, and every transaction's `to` / `value` / `operation` / + /// calldata. Reverts `TxBuilderArtifactMismatch` naming the first + /// mismatching field; returns silently on an exact match. + /// @dev One shared comparison rather than one per script: two copies + /// had already drifted (one skipped `operation`) before this was + /// extracted, and the comparison is exactly the part of a `verify` + /// that must not vary per script — only the bundle re-derivation is + /// script-specific. + /// @param expected The bundle derived from live state. + /// @param jsonPath Filesystem path to the artifact under verification. + function assertParsedTxsMatch(SafeTx[] memory expected, string memory jsonPath) internal view { + (uint256 parsedChainId, address parsedFirstTarget, SafeTx[] memory parsed) = parseTxBuilderJson(jsonPath); + if (parsedChainId != block.chainid) revert TxBuilderArtifactMismatch("chainId"); + if (parsed.length != expected.length) revert TxBuilderArtifactMismatch("txCount"); + if (parsedFirstTarget != expected[0].to) revert TxBuilderArtifactMismatch("firstTarget"); + for (uint256 i = 0; i < expected.length; i++) { + if (parsed[i].to != expected[i].to) revert TxBuilderArtifactMismatch("to"); + if (parsed[i].value != expected[i].value) revert TxBuilderArtifactMismatch("value"); + if (parsed[i].operation != expected[i].operation) revert TxBuilderArtifactMismatch("operation"); + if (keccak256(parsed[i].data) != keccak256(expected[i].data)) revert TxBuilderArtifactMismatch("data"); + } + } + /// @notice Parse a decimal-formatted unsigned integer string. The Tx /// Builder schema serialises `chainId` and `value` as decimal strings, /// so this is the inverse of `vm.toString(uint256)`. diff --git a/test/script/20260729-migrate-governance-to-timelock.t.sol b/test/script/20260729-migrate-governance-to-timelock.t.sol index e3a565d8..00f7f953 100644 --- a/test/script/20260729-migrate-governance-to-timelock.t.sol +++ b/test/script/20260729-migrate-governance-to-timelock.t.sol @@ -16,8 +16,7 @@ import { TimelockNotPinned, UnexpectedVaultOwner, UnexpectedBeaconOwner, - NothingToMigrate, - MigrationVerifyMismatch + NothingToMigrate } from "../../script/20260729-migrate-governance-to-timelock.s.sol"; import {MigrateGovernanceToTimelockHarness} from "./MigrateGovernanceToTimelockHarness.sol"; import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; @@ -30,7 +29,7 @@ import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; import {IAuthorisable} from "../../src/interface/IAuthorisable.sol"; import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; +import {LibSafeOps, SafeTx, TxBuilderArtifactMismatch} from "../../src/lib/LibSafeOps.sol"; import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol"; import {LibTokenInvariants, TokenInstance} from "../../src/lib/LibTokenInvariants.sol"; @@ -490,7 +489,7 @@ contract MigrateGovernanceToTimelockTest is Test { "out/tampered-migration.json", LibSafeOps.emitTxBuilderJson(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, block.chainid, "tampered", txs) ); - vm.expectRevert(abi.encodeWithSelector(MigrationVerifyMismatch.selector, "data")); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "data")); verifier.verify("out/tampered-migration.json"); } diff --git a/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol b/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol new file mode 100644 index 00000000..6c5ce6a4 --- /dev/null +++ b/test/script/20260810-revoke-fireblocks-service-signer.prod.t.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; +import {RevokeFireblocksServiceSignerRemainderHarness} from "./RevokeFireblocksServiceSignerRemainderHarness.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx, TxBuilderArtifactMismatch} from "../../src/lib/LibSafeOps.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; + +/// @title RevokeFireblocksServiceSignerProdTest +/// @notice PROD coverage for the retired-signer revocation: what production +/// IS on each chain carrying a live V4 authoriser, read from a real fork +/// with no mocks anywhere. +/// +/// The revocation is PENDING — the retired Fireblocks signer still holds +/// all three canonical pairs on every chain — so each test asserts the +/// live pre-execution state and then drives `run()` end to end on the +/// fork: full pre-flight against the live Safe + authoriser, a three-tx +/// revoke bundle authored and round-tripped through the Tx Builder JSON, +/// the simulated post-state clean, and the n+1 reversal proven. Once the +/// Safes sign and the bundle executes on a chain, that chain's `run()` +/// reverts `FireblocksSignerAlreadyRevoked` and its test here goes red — +/// the forcing function for the post-execution pin PR, which removes the +/// retired rows from the canonical map, pins their absence, and retires +/// this file the way the executed provisioning retired its predecessor +/// fixtures. +/// +/// @dev No mocks. Each chain's state is whatever the chain says it is. +/// Selection logic — which pairs get authored for a given role state — is +/// covered without a fork in +/// `20260810-revoke-fireblocks-service-signer.t.sol`. +/// +/// The artifact path is chain-suffixed, so the three chains' tests cannot +/// clobber each other's file however forge schedules them. +contract RevokeFireblocksServiceSignerProdTest is Test { + /// @notice The signer this operation revokes. + address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_1C66; + + /// @notice The artifact path `run()` writes for the ACTIVE chain — + /// mirrored from the script's `artifactPath()` so the round-trip below + /// reads the file the dispatch under test actually produced. + /// @return path The chain-suffixed artifact path. + function artifactPath() internal view returns (string memory path) { + path = string.concat("out/20260810-revoke-fireblocks-service-signer-", vm.toString(block.chainid), ".json"); + } + + /// @notice Assert the ACTIVE fork still carries the retired signer's + /// full grant set, then drive the script end to end against it: `run()` + /// authors the three-pair revoke bundle, writes the artifact, leaves + /// the simulated fork revoked, and proves the n+1 reversal. `run()` + /// reaches the authoring only after its full pre-flight passes, so this + /// also pins the Safe, the authoriser pin + codehash, and the rest of + /// the grant map on the live chain. + /// @param label Human chain name, surfaced in assertion messages. + /// @param authoriser The chain's pinned V4 authoriser clone. + /// @param safe The chain's token-owner Safe, filling the map's Safe + /// grantee slots. + function assertActiveForkAuthorsRevocation(string memory label, address authoriser, address safe) internal { + IAccessControl acl = IAccessControl(authoriser); + RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safe); + uint256 pairs = 0; + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee != SIGNER) continue; + pairs++; + assertTrue( + acl.hasRole(all[i].role, all[i].grantee), + string.concat(label, ": retired signer no longer holds a canonical pair - flip the map pin PR") + ); + } + assertEq(pairs, 3, "the canonical map carries the retired signer's three rows"); + + RevokeFireblocksServiceSigner script = new RevokeFireblocksServiceSigner(); + script.run(); + + // The simulated fork ends fully revoked. + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee != SIGNER) continue; + assertFalse( + acl.hasRole(all[i].role, all[i].grantee), + string.concat(label, ": run() left the retired signer holding a canonical pair") + ); + } + + // ...and every OTHER canonical row survives — the replacement + // signer's operational grants, the Safe's action roles, every + // `_ADMIN`. Re-asserted here independently of the script's own + // post-state so this test fails even if those checks rot. + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == SIGNER) continue; + assertTrue( + acl.hasRole(all[i].role, all[i].grantee), + string.concat(label, ": run() disturbed a canonical row it must not touch") + ); + } + + // Round-trip the emitted artifact: scoped to this chain, three + // value-free revoke CALLs against the chain's authoriser, in + // canonical map order. + (uint256 chainId, address firstTarget, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson(artifactPath()); + assertEq(chainId, block.chainid, string.concat(label, ": artifact chain id")); + assertEq(firstTarget, authoriser, string.concat(label, ": artifact target")); + assertEq(txs.length, 3, string.concat(label, ": artifact bundle size")); + bytes32[3] memory roles = [keccak256("DEPOSIT"), keccak256("WITHDRAW"), keccak256("CERTIFY")]; + for (uint256 i = 0; i < txs.length; i++) { + assertEq(txs[i].to, authoriser, string.concat(label, ": artifact tx target")); + assertEq(txs[i].value, 0, string.concat(label, ": artifact tx value")); + assertEq( + txs[i].data, + abi.encodeCall(IAccessControl.revokeRole, (roles[i], SIGNER)), + string.concat(label, ": artifact tx calldata") + ); + } + } + + /// @notice Base still holds the retired signer's grants and authors the + /// full revoke bundle. + function testBaseAuthorsRevocation() external { + vm.createSelectFork(LibRainDeploy.BASE); + assertActiveForkAuthorsRevocation( + "Base", LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE + ); + } + + /// @notice Ethereum still holds the retired signer's grants and authors + /// the full revoke bundle. + function testEthereumAuthorsRevocation() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + assertActiveForkAuthorsRevocation( + "Ethereum", + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM + ); + } + + /// @notice HyperEVM still holds the retired signer's grants and authors + /// the full revoke bundle. + function testHyperEvmAuthorsRevocation() external { + vm.createSelectFork(LibStoxDeployNetworks.HYPEREVM); + assertActiveForkAuthorsRevocation( + "HyperEVM", + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_HYPEREVM, + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_HYPEREVM + ); + } + + /// @notice The signer-side `verify(string)` accepts a freshly authored + /// artifact against a fresh fork of the same live state, and rejects a + /// tampered copy with the exact mismatching field. Authoring simulates + /// the revocation onto its own fork, so verification runs on a NEW + /// fork — the signer's vantage point: artifact in hand, live chain + /// untouched. + function testVerifyAcceptsAuthoredArtifactAndRejectsTamper() external { + vm.createSelectFork(LibRainDeploy.BASE); + new RevokeFireblocksServiceSigner().run(); + string memory path = artifactPath(); + + // Fresh fork: authoring left the previous fork revoked; the live + // chain a signer verifies against still holds the grants. + vm.createSelectFork(LibRainDeploy.BASE); + RevokeFireblocksServiceSigner verifier = new RevokeFireblocksServiceSigner(); + verifier.verify(path); + + // Tamper one call's payload and re-emit: verify pinpoints the field. + (,, SafeTx[] memory txs) = LibSafeOps.parseTxBuilderJson(path); + txs[0].data = abi.encodeCall(IAccessControl.revokeRole, (bytes32(uint256(1)), address(0xBAD))); + vm.writeFile( + "out/tampered-revocation.json", + LibSafeOps.emitTxBuilderJson(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, block.chainid, "tampered", txs) + ); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "data")); + verifier.verify("out/tampered-revocation.json"); + } + + /// @notice `verify` refuses a STALE artifact, not just a tampered one: + /// authored while all three pairs held, then a pair is revoked on the + /// live chain before signing — the re-derived bundle is now two txs and + /// the three-tx artifact must not be signed. This is the + /// authoring-to-signing drift window the entrypoint exists to close, + /// exercised as actual state drift rather than file mutation. + function testVerifyRejectsStaleArtifactAfterDrift() external { + vm.createSelectFork(LibRainDeploy.BASE); + new RevokeFireblocksServiceSigner().run(); + string memory path = artifactPath(); + + // Fresh fork = the live chain at signing time; drift one pair the + // way the bundle's own third leg would have. + vm.createSelectFork(LibRainDeploy.BASE); + vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + IAccessControl(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE).revokeRole(keccak256("CERTIFY"), SIGNER); + + RevokeFireblocksServiceSigner verifier = new RevokeFireblocksServiceSigner(); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "txCount")); + verifier.verify(path); + } + + /// @notice A partial prior execution re-dispatches to exactly the + /// remainder, artifact included: with CERTIFY already revoked on the + /// live fork, `run()` authors and emits a two-tx bundle carrying the + /// DEPOSIT and WITHDRAW revokes only. The unit suite pins this + /// selection in memory; this pins the EMITTED artifact a signer would + /// import for the remainder. + function testBasePartialReDispatchEmitsRemainderArtifact() external { + vm.createSelectFork(LibRainDeploy.BASE); + address authoriser = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + + // The Safe holds CERTIFY_ADMIN, so it can revoke CERTIFY directly — + // the same call the bundle's third leg would have made. + vm.prank(safe); + IAccessControl(authoriser).revokeRole(keccak256("CERTIFY"), SIGNER); + + // The remainder harness writes to its own path: this test's bundle + // CONTENT differs from the full-bundle tests', and forge runs tests + // in parallel, so sharing the canonical path would race. + RevokeFireblocksServiceSignerRemainderHarness script = new RevokeFireblocksServiceSignerRemainderHarness(); + script.run(); + + (uint256 chainId, address firstTarget, SafeTx[] memory txs) = + LibSafeOps.parseTxBuilderJson(script.remainderArtifactPath()); + assertEq(chainId, block.chainid, "remainder artifact chain id"); + assertEq(firstTarget, authoriser, "remainder artifact target"); + assertEq(txs.length, 2, "remainder artifact bundle size"); + bytes32[2] memory roles = [keccak256("DEPOSIT"), keccak256("WITHDRAW")]; + for (uint256 i = 0; i < txs.length; i++) { + assertEq(txs[i].to, authoriser, "remainder artifact tx target"); + assertEq(txs[i].value, 0, "remainder artifact tx value"); + assertEq( + txs[i].data, + abi.encodeCall(IAccessControl.revokeRole, (roles[i], SIGNER)), + "remainder artifact tx calldata" + ); + } + } +} + diff --git a/test/script/20260810-revoke-fireblocks-service-signer.t.sol b/test/script/20260810-revoke-fireblocks-service-signer.t.sol new file mode 100644 index 00000000..7ee09eb0 --- /dev/null +++ b/test/script/20260810-revoke-fireblocks-service-signer.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {Clones} from "@openzeppelin-contracts-5.6.1/proxy/Clones.sol"; +import {ICLONEABLE_V2_SUCCESS} from "rain-factory-0.1.1/src/interface/ICloneableV2.sol"; +import { + OffchainAssetReceiptVaultAuthorizerV1Config +} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; + +import {FireblocksSignerAlreadyRevoked} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; +import { + StoxOffchainAssetReceiptVaultAuthorizerV1 +} from "../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {SafeTx} from "../../src/lib/LibSafeOps.sol"; +import {RevokeFireblocksServiceSignerHarness} from "./RevokeFireblocksServiceSignerHarness.sol"; + +/// @title RevokeFireblocksServiceSignerTest +/// @notice UNIT coverage for the retired-signer revocation SELECTION — +/// which canonical pairs the script authors for a given authoriser state, +/// and when it refuses to author at all. +/// +/// No fork and no mocks. Every fixture is a real EIP-1167 clone of the real +/// `StoxOffchainAssetReceiptVaultAuthorizerV1`, driven into the state under +/// test by real `grantRole`/`revokeRole` calls made by the address holding +/// the role's `_ADMIN`. The role state is therefore produced by the same +/// code path production uses, and no assertion here can be invalidated by +/// anything signed on-chain. +/// +/// The pairs come from `LibAuthoriserInvariants.expectedGrants()`, the +/// single current-state map every live invariant asserts, so the fixture +/// cannot drift from the map the script reads. +/// +/// Live per-chain state is asserted separately, unmocked, in +/// `20260810-revoke-fireblocks-service-signer.prod.t.sol`. +contract RevokeFireblocksServiceSignerTest is Test { + /// @notice The signer being revoked — the canonical pin the script + /// scopes itself to. + address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_1C66; + + /// @notice Stand-in for a chain's token-owner Safe. The selection only + /// needs an address that holds the `_ADMIN` roles and fills the map's + /// Safe grantee slots; a locally cloned authoriser can be initialised + /// with any such address, so no Safe bytecode is involved. + address internal constant SAFE = address(uint160(uint256(keccak256("unit.token.owner.safe")))); + + bytes32 internal constant DEPOSIT = keccak256("DEPOSIT"); + bytes32 internal constant WITHDRAW = keccak256("WITHDRAW"); + bytes32 internal constant CERTIFY = keccak256("CERTIFY"); + + /// @notice A locally cloned V4 authoriser carrying EVERY canonical row, + /// the retired signer's three included — the pre-revocation state, i.e. + /// the state the script was written against. `initialize` auto-grants + /// `SAFE` the seven `_ADMIN` roles; the remaining rows are then granted + /// by `SAFE` under those admins. The loop is driven by + /// `expectedGrants()` itself, so a new canonical row lands in the + /// fixture without editing it. + /// @return authoriser The initialised clone. + function deployFullyGrantedAuthoriser() internal returns (address authoriser) { + StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); + authoriser = Clones.clone(address(impl)); + assertEq( + StoxOffchainAssetReceiptVaultAuthorizerV1(authoriser) + .initialize(abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: SAFE}))), + ICLONEABLE_V2_SUCCESS, + "authoriser clone failed to initialise" + ); + + IAccessControl acl = IAccessControl(authoriser); + RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(SAFE); + for (uint256 i = 0; i < all.length; i++) { + if (acl.hasRole(all[i].role, all[i].grantee)) continue; + vm.prank(SAFE); + acl.grantRole(all[i].role, all[i].grantee); + } + } + + /// @notice Really revoke `role` from the retired signer, as the Safe. + /// @param authoriser The clone to revoke on. + /// @param role The action role to revoke. + function revokeFromSigner(address authoriser, bytes32 role) internal { + vm.prank(SAFE); + IAccessControl(authoriser).revokeRole(role, SIGNER); + } + + /// @notice Assert the returned canonical pairs are exactly the retired + /// signer's three rows, in map order. Role, grantee AND order all + /// matter: order decides which pair drives the reversal walk in + /// `run()`, and the grantee is what makes these the retired signer's + /// rows rather than another grantee's. Invariant across every state, + /// because what the signer still holds changes the BUNDLE, never the + /// map. + /// @param retired The pairs returned alongside the bundle. + function assertCanonicalPairs(RoleGrant[] memory retired) internal pure { + assertEq(retired.length, 3, "the canonical map carries the retired signer's three rows"); + bytes32[3] memory roles = [DEPOSIT, WITHDRAW, CERTIFY]; + for (uint256 i = 0; i < roles.length; i++) { + assertEq(retired[i].role, roles[i], "canonical pair role"); + assertEq(retired[i].grantee, SIGNER, "canonical pair grantee"); + } + } + + /// @notice Assert `txn` is the `revokeRole(role, SIGNER)` call on + /// `authoriser` — a plain value-free CALL, the only shape the Safe Tx + /// Builder artifact can carry. + /// @param txn The authored transaction. + /// @param authoriser The expected destination. + /// @param role The expected role in the encoded call. + function assertRevokesRole(SafeTx memory txn, address authoriser, bytes32 role) internal pure { + assertEq(txn.to, authoriser, "revoke tx must target the authoriser"); + assertEq(txn.value, 0, "revoke tx must carry no value"); + assertEq(uint256(txn.operation), 0, "revoke tx must be a CALL"); + assertEq(txn.data, abi.encodeCall(IAccessControl.revokeRole, (role, SIGNER)), "revoke tx calldata"); + } + + /// @notice Everything still held: all three canonical pairs are + /// authored, in canonical map order. + function testAuthorsEveryPairWhenEveryPairIsHeld() external { + address authoriser = deployFullyGrantedAuthoriser(); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(retired); + assertEq(txs.length, 3, "every pair is still held, so every pair is authored"); + assertRevokesRole(txs[0], authoriser, DEPOSIT); + assertRevokesRole(txs[1], authoriser, WITHDRAW); + assertRevokesRole(txs[2], authoriser, CERTIFY); + } + + /// @notice Partial execution recovers by re-dispatch: with CERTIFY + /// already revoked, only DEPOSIT and WITHDRAW are authored. + function testAuthorsOnlyTheHeldPairsWhenCertifyIsRevoked() external { + address authoriser = deployFullyGrantedAuthoriser(); + revokeFromSigner(authoriser, CERTIFY); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(retired); + assertEq(txs.length, 2, "the revoked pair is not re-authored"); + assertRevokesRole(txs[0], authoriser, DEPOSIT); + assertRevokesRole(txs[1], authoriser, WITHDRAW); + } + + /// @notice The self-scoping tracks WHICH pair is held, not merely how + /// many: with the FIRST canonical pair already revoked the remaining + /// two are authored, so the bundle cannot be a prefix of the map. + function testAuthorsOnlyTheHeldPairsWhenDepositIsRevoked() external { + address authoriser = deployFullyGrantedAuthoriser(); + revokeFromSigner(authoriser, DEPOSIT); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + (RoleGrant[] memory retired, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(retired); + assertEq(txs.length, 2, "the revoked pair is not re-authored"); + assertRevokesRole(txs[0], authoriser, WITHDRAW); + assertRevokesRole(txs[1], authoriser, CERTIFY); + } + + /// @notice A fully revoked authoriser refuses to author an empty + /// bundle — the post-execution state, reached here by really revoking + /// all three pairs. + function testRevertsWhenEveryPairIsAlreadyRevoked() external { + address authoriser = deployFullyGrantedAuthoriser(); + revokeFromSigner(authoriser, DEPOSIT); + revokeFromSigner(authoriser, WITHDRAW); + revokeFromSigner(authoriser, CERTIFY); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + vm.expectRevert(FireblocksSignerAlreadyRevoked.selector); + harness.callAuthorBundle(authoriser, SAFE); + } + + /// @notice The revoke is never authored on an authoriser whose + /// configuration has drifted: a missing NON-retired row aborts the + /// authoring before any pair is selected. The row removed here is one + /// of the REPLACEMENT signer's — this is the rotation gate: an + /// under-provisioned replacement blocks the revoke, so the service can + /// never be left without an active signer. + function testRevertsWhenTheReplacementSignerIsUnderProvisioned() external { + address authoriser = deployFullyGrantedAuthoriser(); + vm.prank(SAFE); + IAccessControl(authoriser).revokeRole(DEPOSIT, LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + vm.expectRevert(bytes("RevokeFireblocksServiceSigner: authoriser grant map has drifted")); + harness.callAuthorBundle(authoriser, SAFE); + } + + /// @notice The drift guard is not specific to the replacement signer: + /// a missing Safe-grantee row aborts the authoring identically. + function testRevertsWhenTheGrantMapHasDrifted() external { + address authoriser = deployFullyGrantedAuthoriser(); + vm.prank(SAFE); + IAccessControl(authoriser).revokeRole(CERTIFY, SAFE); + RevokeFireblocksServiceSignerHarness harness = new RevokeFireblocksServiceSignerHarness(); + + vm.expectRevert(bytes("RevokeFireblocksServiceSigner: authoriser grant map has drifted")); + harness.callAuthorBundle(authoriser, SAFE); + } +} diff --git a/test/script/RevokeFireblocksServiceSignerHarness.sol b/test/script/RevokeFireblocksServiceSignerHarness.sol new file mode 100644 index 00000000..c216c26c --- /dev/null +++ b/test/script/RevokeFireblocksServiceSignerHarness.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; +import {RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {SafeTx} from "../../src/lib/LibSafeOps.sol"; + +/// @title RevokeFireblocksServiceSignerHarness +/// @notice Exposes the script's internal bundle authoring so the selection +/// logic can be driven directly against any authoriser, and so +/// `vm.expectRevert` can intercept the typed errors it raises (a +/// library-internal revert inlines, and `expectRevert` only sees reverts +/// from a lower call depth than the cheatcode itself). Its own file because +/// Rain convention is one contract per .sol and +/// `rainix-sol-single-contract` enforces it. Mirrors +/// `test/script/ProvisionAdditionalServiceSignerHarness.sol`. +contract RevokeFireblocksServiceSignerHarness is RevokeFireblocksServiceSigner { + /// @notice The script's `authorBundle()`, externally callable. + /// @param authoriser The authoriser whose live role state is read. + /// @param safeAddr The token-owner Safe filling the map's Safe slots. + /// @return retired The retired signer's canonical pairs, in map order. + /// @return txs One `revokeRole` tx per pair the signer still holds. + function callAuthorBundle(address authoriser, address safeAddr) + external + view + returns (RoleGrant[] memory retired, SafeTx[] memory txs) + { + return authorBundle(authoriser, safeAddr); + } +} diff --git a/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol b/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol new file mode 100644 index 00000000..c9e30255 --- /dev/null +++ b/test/script/RevokeFireblocksServiceSignerRemainderHarness.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {RevokeFireblocksServiceSigner} from "../../script/20260810-revoke-fireblocks-service-signer.s.sol"; + +/// @title RevokeFireblocksServiceSignerRemainderHarness +/// @notice The partial-re-dispatch fixture's script: identical to the +/// production script except the artifact lands on a dedicated path. The +/// partial test's bundle content DIFFERS from the full-bundle tests' on +/// the same chain, and forge runs tests in parallel, so writing the +/// canonical path would race with them. Its own file because Rain +/// convention is one contract per .sol. +contract RevokeFireblocksServiceSignerRemainderHarness is RevokeFireblocksServiceSigner { + /// @notice The dedicated remainder-artifact path, exposed so the test + /// parses exactly the file this fixture wrote. + /// @return path The remainder artifact path for the ACTIVE chain. + function remainderArtifactPath() public view returns (string memory path) { + path = string.concat("out/20260810-revoke-remainder-", vm.toString(block.chainid), ".json"); + } + + function artifactPath() internal view override returns (string memory path) { + path = remainderArtifactPath(); + } +} diff --git a/test/src/lib/LibSafeOps.t.sol b/test/src/lib/LibSafeOps.t.sol index 6e300c6c..3c9226ae 100644 --- a/test/src/lib/LibSafeOps.t.sol +++ b/test/src/lib/LibSafeOps.t.sol @@ -7,7 +7,8 @@ import { LibSafeOps, SafeTx, TxBuilderJsonNoTransactions, - TxBuilderJsonUnsupportedOperation + TxBuilderJsonUnsupportedOperation, + TxBuilderArtifactMismatch } from "../../../src/lib/LibSafeOps.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; @@ -256,6 +257,69 @@ contract LibSafeOpsTest is Test { } } + /// @notice `assertParsedTxsMatch` walks every comparison it makes: an + /// exact emit+parse round-trip passes silently, and each field an + /// artifact could lie about — chain id, transaction count, first + /// target, per-tx target, value, calldata — trips + /// `TxBuilderArtifactMismatch` naming exactly that field. Each mismatch + /// is exercised with every OTHER field intact, so deleting any single + /// comparison fails this test. `operation` is not walked: the JSON + /// layer cannot represent a non-CALL (emit rejects it, parse hardcodes + /// zero), so no artifact can reach that comparison — it guards + /// non-JSON callers handing in a `SafeTx[]` directly. + function testAssertParsedTxsMatchWalksEveryField() external { + ParseHarness harness = new ParseHarness(); + SafeTx[] memory expected = _multiTxBundle(); + string memory path = string.concat(vm.projectRoot(), "/out/test-match-walk.json"); + + // Exact match passes silently. + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(expected[0].to, block.chainid, "match", expected)); + harness.callAssertParsedTxsMatch(expected, path); + + // chainId: authored for a different chain. + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(expected[0].to, block.chainid + 1, "match", expected)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "chainId")); + harness.callAssertParsedTxsMatch(expected, path); + + // txCount: the artifact carries one fewer transaction. + SafeTx[] memory shorter = new SafeTx[](2); + shorter[0] = expected[0]; + shorter[1] = expected[1]; + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(expected[0].to, block.chainid, "match", shorter)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "txCount")); + harness.callAssertParsedTxsMatch(expected, path); + + // firstTarget: the FIRST tx's target moved — checked before the + // per-tx walk, so it names its own field. + SafeTx[] memory mutated = _multiTxBundle(); + mutated[0].to = address(0xF157); + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(mutated[0].to, block.chainid, "match", mutated)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "firstTarget")); + harness.callAssertParsedTxsMatch(expected, path); + + // to: a LATER tx's target moved, so firstTarget still matches and + // the per-tx walk names the field. + mutated = _multiTxBundle(); + mutated[1].to = address(0xF157); + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(mutated[0].to, block.chainid, "match", mutated)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "to")); + harness.callAssertParsedTxsMatch(expected, path); + + // value. + mutated = _multiTxBundle(); + mutated[1].value = mutated[1].value + 1; + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(mutated[0].to, block.chainid, "match", mutated)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "value")); + harness.callAssertParsedTxsMatch(expected, path); + + // data. + mutated = _multiTxBundle(); + mutated[2].data = hex"00ff01"; + vm.writeFile(path, LibSafeOps.emitTxBuilderJson(mutated[0].to, block.chainid, "match", mutated)); + vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "data")); + harness.callAssertParsedTxsMatch(expected, path); + } + /// @notice The emitted JSON for a multi-transaction bundle contains exactly /// one entry per transaction, in order, with no trailing entry. function testEmittedJsonShapeMultiTx() external view { diff --git a/test/src/lib/ParseHarness.sol b/test/src/lib/ParseHarness.sol index 41998cd0..009a0758 100644 --- a/test/src/lib/ParseHarness.sol +++ b/test/src/lib/ParseHarness.sol @@ -2,14 +2,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {LibSafeOps} from "../../../src/lib/LibSafeOps.sol"; +import {LibSafeOps, SafeTx} from "../../../src/lib/LibSafeOps.sol"; -/// @notice External-call harness around `LibSafeOps.parseTxBuilderJson` so -/// `vm.expectRevert` can catch the typed error. `expectRevert` only sees -/// reverts that bubble from a lower call depth than the cheatcode itself, -/// and library-internal reverts inline. +/// @notice External-call harness around `LibSafeOps.parseTxBuilderJson` / +/// `assertParsedTxsMatch` so `vm.expectRevert` can catch the typed errors. +/// `expectRevert` only sees reverts that bubble from a lower call depth +/// than the cheatcode itself, and library-internal reverts inline. contract ParseHarness { function callParse(string calldata jsonPath) external view { LibSafeOps.parseTxBuilderJson(jsonPath); } + + function callAssertParsedTxsMatch(SafeTx[] calldata expected, string calldata jsonPath) external view { + LibSafeOps.assertParsedTxsMatch(expected, jsonPath); + } }