diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index fb167afb..9b7d7467 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -29,6 +29,7 @@ on: - 20260623-upgrade-receipt-vaults-to-v4 - 20260722-swap-rklb-authoriser - 20260722-swap-remaining-vault-authorisers + - 20260723-provision-additional-service-signer sig: description: 'Entrypoint to dispatch (default: run())' required: true @@ -44,6 +45,18 @@ on: # JSON path argument this dispatcher can't supply and runs off-chain # on the signer's machine, not in CI. - 'run()' + network: + description: 'Network whose authoriser/Safe the artifact targets (default: base)' + required: true + type: choice + default: 'base' + options: + # The chain to fork when authoring — the foundry.toml + # `[rpc_endpoints]` alias. Chain-aware scripts author for + # whichever network is selected; chain-pinned scripts simply + # revert on the wrong network. + - 'base' + - 'ethereum' # Manually dispatches an operational script from `script/` and uploads any # JSON it writes to `out/` as a build artifact. # @@ -72,7 +85,7 @@ jobs: # Serialise dispatches of the same script+sig so overlapping runs don't # race on the shared out/ artifacts; distinct entrypoints run in parallel. concurrency: - group: run-script-${{ inputs.script }}-${{ inputs.sig }} + group: run-script-${{ inputs.script }}-${{ inputs.sig }}-${{ inputs.network }} cancel-in-progress: false steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 @@ -84,15 +97,17 @@ jobs: - name: Run script env: BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }} + ETHEREUM_RPC_URL: ${{ secrets.RPC_URL_ETHEREUM_FORK }} # Pass the choice inputs via env rather than template-expanding them # into the command, so a dispatch (even one crafted via the API) is # used as a literal argument and cannot inject shell. SCRIPT: ${{ inputs.script }} SIG: ${{ inputs.sig }} + NETWORK: ${{ inputs.network }} run: | nix develop --command forge script "script/${SCRIPT}.s.sol" \ --sig "${SIG}" \ - --rpc-url base \ + --rpc-url "${NETWORK}" \ --no-storage-caching - name: Upload script output uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/script/20260619-deploy-v4-authoriser-clone.s.sol b/script/20260619-deploy-v4-authoriser-clone.s.sol index 78ce2b52..baaa0d4d 100644 --- a/script/20260619-deploy-v4-authoriser-clone.s.sol +++ b/script/20260619-deploy-v4-authoriser-clone.s.sol @@ -131,7 +131,7 @@ contract DeployV4AuthoriserClone is Script { uint256 internal constant MIRROR_START_INDEX = 7; /// @notice The number of non-admin grants this script mirrors in. - uint256 internal constant MIRROR_COUNT = 6; + uint256 internal constant MIRROR_COUNT = 9; /// @notice The number of `_ADMIN` roles the base + ST0x-override /// `initialize` auto-grant to `initialAdmin` (five base + two diff --git a/script/20260723-provision-additional-service-signer.s.sol b/script/20260723-provision-additional-service-signer.s.sol new file mode 100644 index 00000000..872ba7b9 --- /dev/null +++ b/script/20260723-provision-additional-service-signer.s.sol @@ -0,0 +1,287 @@ +// 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 additional service signer already holds every canonical +/// additional grant on this chain — the provisioning has executed and there +/// is nothing left to author. +error AdditionalSignerAlreadyProvisioned(); + +/// @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 AuthoriserNotReadyForProvisioning(address authoriser); + +/// @notice No V4 authoriser pin exists for the active chain at all. +/// @param chainId The unsupported chain id. +error UnsupportedChainForProvisioning(uint256 chainId); + +/// @notice The Safe does not hold the `_ADMIN` role that admins one of the +/// provisioned roles — the grant would revert inside the Safe tx. +/// @param adminRole The missing admin role. +error SafeMissingRoleAdmin(bytes32 adminRole); + +/// @title ProvisionAdditionalServiceSigner +/// @notice **EXECUTED — verified 2026-08-05 (Base and Ethereum).** Authors +/// the Safe bundle that provisions the ADDITIONAL service signer +/// (`LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C`) on the ACTIVE chain's +/// V4 authoriser with the canonical post-ceremony grants +/// (`additionalServiceGrants()`: `DEPOSIT` / `WITHDRAW` / `CERTIFY` — the +/// same three action roles the original service signer holds; both signers +/// are active side by side, nothing is revoked). Dispatch via +/// `Actions → run-script` with +/// `script = 20260723-provision-additional-service-signer`, `sig = run()`, +/// and the target `network`; one dispatch + Safe signing per chain carrying +/// a live authoriser. Base and Ethereum are the only chains this script +/// dispatches against — `activeChainAuthoriser()` reverts +/// `UnsupportedChainForProvisioning` for every other chain id, HyperEVM +/// included, which needs a branch here once its authoriser bootstraps. +/// Both supported chains hold every canonical pair, so re-dispatching +/// reverts `AdditionalSignerAlreadyProvisioned` — the state +/// `20260723-provision-additional-service-signer.prod.t.sol` pins per +/// chain. +/// +/// The canonical pairs are the `GRANTEE_SERVICE_3D0C` rows of +/// `LibAuthoriserInvariants.expectedGrants()` — the single current-state +/// map every live invariant asserts (the cross-chain parity authoriser +/// leg, the multichain production-state bundle, the per-chain prod pins) — +/// so each chain is RED on this signer's rows until this bundle executes +/// there, and guarded against drift thereafter. +/// +/// SELF-SCOPING: only the pairs the signer does not yet hold are authored +/// (partial execution recovers by re-dispatch); a fully provisioned 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; the ceremony +/// grant map intact; Safe holds the `_ADMIN` role for every provisioned +/// role) -> build one `grantRole` tx per missing pair -> SafeTxHash against +/// the live nonce -> simulate as the Safe -> post-state (every additional +/// pair holds; ceremony map untouched; Safe identity + threshold +/// unchanged) -> artifact to +/// `out/20260723-additional-service-signer.json` -> n+1 walk proving the +/// provisioning is REVERSIBLE (revoke sits under the same `_ADMIN` the +/// Safe holds): revoke the first role under the live threshold, then +/// re-grant so the simulated fork ends provisioned. +contract ProvisionAdditionalServiceSigner is Script { + /// @notice The additional service signer being provisioned — the + /// canonical pin from the invariant lib. + address internal constant ADDITIONAL_SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C; + + /// @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 provision: additional service signer on the V4 authoriser"; + + /// @notice Output path (relative to the project root) for the Tx Builder + /// JSON artifact. + string internal constant ARTIFACT_PATH = "out/20260723-additional-service-signer.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("ProvisionAdditionalServiceSigner: 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 { + revert UnsupportedChainForProvisioning(block.chainid); + } + if ( + authoriser == address(0) || authoriser.code.length == 0 + || authoriser.codehash != LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH + ) { + revert AuthoriserNotReadyForProvisioning(authoriser); + } + } + + /// @notice Pre-flight the authoriser's grant map and self-scope the + /// bundle: the signer's canonical pairs, plus one `grantRole` + /// transaction per pair the signer does not yet hold. + /// + /// Reverts when the map has drifted (any non-signer row missing), when + /// the Safe does not admin a provisioned role, or when every pair + /// already holds (`AdditionalSignerAlreadyProvisioned` — 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 additional The signer's canonical pairs, in map order. + /// @return txs One `grantRole` tx per pair the signer does not hold, + /// in map order. + function authorBundle(address authoriser, address safeAddr) + internal + view + returns (RoleGrant[] memory additional, SafeTx[] memory txs) + { + IAccessControl acl = IAccessControl(authoriser); + + // Split the canonical map: the additional signer's rows are the + // work items; every OTHER row must already hold — the signer must + // never be provisioned on an authoriser whose configuration has + // drifted. + RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(safeAddr); + uint256 pairCount = 0; + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == ADDITIONAL_SIGNER) { + pairCount++; + continue; + } + require( + acl.hasRole(all[i].role, all[i].grantee), + "ProvisionAdditionalServiceSigner: authoriser grant map has drifted" + ); + } + additional = new RoleGrant[](pairCount); + uint256 p = 0; + for (uint256 i = 0; i < all.length; i++) { + if (all[i].grantee == ADDITIONAL_SIGNER) { + additional[p] = all[i]; + p++; + } + } + + // The Safe holds the `_ADMIN` role for every provisioned role. + for (uint256 i = 0; i < additional.length; i++) { + bytes32 adminRole = roleAdminOf(additional[i].role); + if (!acl.hasRole(adminRole, safeAddr)) { + revert SafeMissingRoleAdmin(adminRole); + } + } + + // Self-scope to the pairs not yet held. + bool[] memory missing = new bool[](additional.length); + uint256 count = 0; + for (uint256 i = 0; i < additional.length; i++) { + if (!acl.hasRole(additional[i].role, additional[i].grantee)) { + missing[i] = true; + count++; + } + } + if (count == 0) { + revert AdditionalSignerAlreadyProvisioned(); + } + + txs = new SafeTx[](count); + uint256 t = 0; + for (uint256 i = 0; i < additional.length; i++) { + if (!missing[i]) continue; + txs[t] = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (additional[i].role, additional[i].grantee)), + operation: 0 + }); + t++; + } + } + + /// @notice Author the provisioning 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 additional, SafeTx[] memory txs) = authorBundle(authoriser, safeAddr); + + uint256 nonce = safe.nonce(); + bytes32 firstSafeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txs[0], nonce); + + // --- Simulate ----------------------------------------------------- + + for (uint256 i = 0; i < txs.length; i++) { + LibSafeOps.simulateExternalCall(safe, txs[i].to, txs[i].data); + } + + // --- Post-state --------------------------------------------------- + + // The full current-state map holds — the exact bundle + // `assertExpectedGrants` enforces in CI — and the Safe identity + + // threshold are unchanged. + LibAuthoriserInvariants.assertExpectedGrants(authoriser, 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(ARTIFACT_PATH, json); + + console2.log("==== TX BUILDER JSON BEGIN ===="); + console2.log(json); + console2.log("==== TX BUILDER JSON END ===="); + console2.log("First-tx SafeTxHash:", vm.toString(firstSafeTxHash)); + console2.log("Nonce:", nonce); + console2.log("Bundle item count:", txs.length); + console2.log("Chain:", block.chainid); + console2.log("Authoriser:", authoriser); + console2.log("Additional signer:", ADDITIONAL_SIGNER); + + // --- n+1 reversal proof -------------------------------------------- + + // The provisioning is fully reversible (revoke sits under the same + // `_ADMIN` the Safe holds). Prove the revoke clears the live + // threshold on the first role, then re-grant so the fork ends + // provisioned. + LibSafeOps.simulateNPlus1( + safe, + authoriser, + abi.encodeCall(IAccessControl.revokeRole, (additional[0].role, additional[0].grantee)), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD + ); + require( + !acl.hasRole(additional[0].role, additional[0].grantee), + "ProvisionAdditionalServiceSigner: n+1 revoke did not remove the role" + ); + LibSafeOps.simulateExternalCall( + safe, authoriser, abi.encodeCall(IAccessControl.grantRole, (additional[0].role, additional[0].grantee)) + ); + console2.log("n+1 reversal check passed: the Safe can revoke (and re-grant) under the live threshold"); + } +} diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol index 7201c861..931868aa 100644 --- a/src/lib/LibAuthoriserInvariants.sol +++ b/src/lib/LibAuthoriserInvariants.sol @@ -86,6 +86,14 @@ library LibAuthoriserInvariants { /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; + /// @notice ADDITIONAL service EOA, holding the same three action roles + /// as `GRANTEE_SERVICE_1C66` — both signers are active side by side. + /// Provisioned on each live chain's authoriser by the + /// `20260723-provision-additional-service-signer` Safe bundle; the + /// ADDRESS is shared across chains while the grants are per-chain + /// state. + address internal constant GRANTEE_SERVICE_3D0C = 0x3d0CD66EFA66c05d86c3d4316B03eAE87ab9E8aE; + /// @notice The full `(role, grantee)` map in effect on the Base /// production authoriser. Delegates to the Safe-parametric overload with /// Base's token-owner Safe. @@ -94,23 +102,18 @@ library LibAuthoriserInvariants { grants = expectedGrants(GRANTEE_TOKEN_OWNER_SAFE); } - /// @notice The `(role, grantee)` map every ST0x authoriser carries, - /// parameterised on the chain's token-owner Safe. The STRUCTURE — 7 - /// `_ADMIN` roles + 3 direct action roles held by the Safe, 3 action roles - /// held by the shared service signer — is identical on every chain; the - /// only per-chain input is the Safe ADDRESS (the service signer is shared), - /// because the Safe address is a per-chain deploy artifact. Every chain - /// targets the 0.1.1 authoriser impl, so every chain carries the two - /// corporate-action admins. The 13 entries split into: 7 `_ADMIN` roles - /// held by the Safe (5 from the base `initialize`, 2 added by the 0.1.1 - /// ST0x override and granted to the Safe per RAI-731), 3 action roles for - /// the service EOA, 3 action roles the Safe holds for direct operational - /// use. + /// @notice The canonical `(role, grantee)` map the current production + /// authoriser must carry, parameterised on the chain's token-owner Safe + /// (the STRUCTURE is chain-agnostic; service signers are shared across + /// chains, the Safe address is per-chain). The single source of truth + /// every live-state invariant asserts: a chain is red on any pair until + /// the operation that grants it executes there, and drift-guarded + /// thereafter. /// @param tokenOwnerSafe The chain's token-owner Safe filling the Safe /// grantee slots. /// @return grants The `(role, grantee)` pairs for that chain. function expectedGrants(address tokenOwnerSafe) internal pure returns (RoleGrant[] memory grants) { - grants = new RoleGrant[](13); + grants = new RoleGrant[](16); // Init grants (block 41715184 on Base) — Safe receives every `_ADMIN`. grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), tokenOwnerSafe); @@ -135,6 +138,12 @@ library LibAuthoriserInvariants { grants[10] = RoleGrant(keccak256("DEPOSIT"), tokenOwnerSafe); grants[11] = RoleGrant(keccak256("WITHDRAW"), tokenOwnerSafe); grants[12] = RoleGrant(keccak256("CERTIFY"), tokenOwnerSafe); + + // Additional service signer, provisioned by the 20260723 bundle + // per chain. + grants[13] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_3D0C); + grants[14] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_3D0C); + grants[15] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_3D0C); } /// @notice Assert every pinned `(role, grantee)` pair in @@ -174,6 +183,9 @@ library LibAuthoriserInvariants { if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_1C66)) { revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_1C66); } + if (acl.hasRole(DEFAULT_ADMIN_ROLE, GRANTEE_SERVICE_3D0C)) { + revert UnexpectedDefaultAdmin(authoriser, GRANTEE_SERVICE_3D0C); + } RoleGrant[] memory grants = expectedGrants(tokenOwnerSafe); for (uint256 i = 0; i < grants.length; i++) { if (!acl.hasRole(grants[i].role, grants[i].grantee)) { diff --git a/test/script/20260619-deploy-v4-authoriser-clone.t.sol b/test/script/20260619-deploy-v4-authoriser-clone.t.sol index ec12dfae..28b2b15a 100644 --- a/test/script/20260619-deploy-v4-authoriser-clone.t.sol +++ b/test/script/20260619-deploy-v4-authoriser-clone.t.sol @@ -332,7 +332,7 @@ contract DeployV4AuthoriserCloneTest is Test { selectBaseFork(); RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); assertEq(harness.mirrorStartIndex(), 7, "MIRROR_START_INDEX drifted from the happy-path replica"); - assertEq(harness.mirrorCount(), 6, "MIRROR_COUNT drifted from the happy-path replica"); + assertEq(harness.mirrorCount(), 9, "MIRROR_COUNT drifted from the happy-path replica"); assertEq( harness.mirrorStartIndex() + harness.mirrorCount(), allGrants.length, diff --git a/test/script/20260722-swap-remaining-vault-authorisers.t.sol b/test/script/20260722-swap-remaining-vault-authorisers.t.sol deleted file mode 100644 index deb84c6f..00000000 --- a/test/script/20260722-swap-remaining-vault-authorisers.t.sol +++ /dev/null @@ -1,89 +0,0 @@ -// 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 {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; -import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; - -import { - SwapRemainingVaultAuthorisers, - NoVaultsLeftToSwap, - UnexpectedVaultAuthoriser -} from "../../script/20260722-swap-remaining-vault-authorisers.s.sol"; -import {LibAuthoriserInvariants} from "../../src/lib/LibAuthoriserInvariants.sol"; -import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; -import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; - -/// @title SwapRemainingVaultAuthorisersTest -/// @notice Live-fork coverage for the follow-up authoriser swap authoring. -/// The script is self-scoping (targets = every production vault still on the -/// V3 authoriser, read live), so these tests derive the same target set from -/// the fork before driving `run()` and assert the authored bundle matches it -/// exactly — the same assertion a signer makes when reviewing the artifact. -/// @dev Unpinned Base head fork, so the tests track live state. The batch has -/// EXECUTED — no production vault is on V3 as of 2026-07-23 — so the target -/// set is empty and the happy path is gone: authoring now reverts -/// `NoVaultsLeftToSwap`, which `testRunRevertsWhenNothingLeftToSwap` asserts -/// directly. What remains is the coverage that still means something for a -/// script kept for re-dispatch: the empty-set refusal, and the abort on an -/// authoriser neither V3 nor V4. -contract SwapRemainingVaultAuthorisersTest is Test { - SwapRemainingVaultAuthorisers internal script; - - function selectBaseFork() internal { - vm.createSelectFork(LibRainDeploy.BASE); - script = new SwapRemainingVaultAuthorisers(); - } - - /// @notice The still-V3 vaults, derived from the live fork exactly the - /// way the script's `_selectTargets` does — the test-side replica the - /// authored bundle is checked against. - function liveV3Vaults() internal view returns (address[] memory targets) { - address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); - address[] memory candidates = new address[](vaults.length); - uint256 count = 0; - for (uint256 i = 0; i < vaults.length; i++) { - if (address(IAuthorizableV1(vaults[i]).authorizer()) == LibAuthoriserInvariants.STOX_PROD_AUTHORISER) { - candidates[count] = vaults[i]; - count++; - } - } - targets = new address[](count); - for (uint256 i = 0; i < count; i++) { - targets[i] = candidates[i]; - } - } - - /// @notice `run()` reverts `NoVaultsLeftToSwap` when the whole table is - /// already on the V4 clone. Simulated by mocking every still-V3 vault's - /// `authorizer()` to the clone — the exact post-execution state, so this - /// also documents how the happy path dies once the batch lands. - function testRunRevertsWhenNothingLeftToSwap() external { - selectBaseFork(); - address[] memory targets = liveV3Vaults(); - for (uint256 i = 0; i < targets.length; i++) { - vm.mockCall( - targets[i], - abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), - abi.encode(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE) - ); - } - vm.expectRevert(NoVaultsLeftToSwap.selector); - script.run(); - } - - /// @notice `run()` reverts `UnexpectedVaultAuthoriser` when any - /// production vault reports an authoriser that is neither V3 nor the V4 - /// clone — unknown drift must abort the authoring, never be papered over - /// with a blind `setAuthorizer`. - function testRunRejectsUnknownAuthoriser() external { - selectBaseFork(); - address[] memory vaults = LibTokenInvariants.productionReceiptVaults(); - address victim = vaults[0]; - address rogue = makeAddr("rogueAuthoriser"); - vm.mockCall(victim, abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), abi.encode(rogue)); - vm.expectRevert(abi.encodeWithSelector(UnexpectedVaultAuthoriser.selector, victim, rogue)); - script.run(); - } -} diff --git a/test/script/20260722-swap-rklb-authoriser.t.sol b/test/script/20260722-swap-rklb-authoriser.t.sol deleted file mode 100644 index 381b1a4c..00000000 --- a/test/script/20260722-swap-rklb-authoriser.t.sol +++ /dev/null @@ -1,63 +0,0 @@ -// 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 {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; -import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol"; - -import { - SwapRklbAuthoriser, - RklbAlreadySwapped, - UnexpectedRklbAuthoriser -} from "../../script/20260722-swap-rklb-authoriser.s.sol"; -import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; -import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; - -/// @title SwapRklbAuthoriserTest -/// @notice Live-fork coverage for the RKLB-only swap authoring. RKLB gets a -/// dedicated single-tx bundle because the six-vault bundle from the general -/// swap script was already partially signed when RKLB entered the table — -/// regenerating a combined bundle would void those signatures. -/// @dev Unpinned Base head fork. The swap EXECUTED 2026-07-23, so the happy -/// path is gone: `run()` now reverts `RklbAlreadySwapped` against live Base, -/// which is the state `testRunRevertsWhenAlreadySwapped` asserts directly. -/// What remains is the inverted coverage — already-swapped, unknown -/// authoriser — which is the coverage that keeps meaning something after -/// execution. -contract SwapRklbAuthoriserTest is Test { - SwapRklbAuthoriser internal script; - - function selectBaseFork() internal { - vm.createSelectFork(LibRainDeploy.BASE); - script = new SwapRklbAuthoriser(); - } - - /// @notice `run()` reverts `RklbAlreadySwapped` once the vault reports - /// the V4 authoriser — the exact post-execution state. - function testRunRevertsWhenAlreadySwapped() external { - selectBaseFork(); - vm.mockCall( - LibTokenInvariants.RKLB_RECEIPT_VAULT, - abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), - abi.encode(LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE) - ); - vm.expectRevert( - abi.encodeWithSelector(RklbAlreadySwapped.selector, LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE) - ); - script.run(); - } - - /// @notice `run()` reverts `UnexpectedRklbAuthoriser` on unknown drift. - function testRunRejectsUnknownAuthoriser() external { - selectBaseFork(); - address rogue = makeAddr("rogueAuthoriser"); - vm.mockCall( - LibTokenInvariants.RKLB_RECEIPT_VAULT, - abi.encodeWithSelector(IAuthorizableV1.authorizer.selector), - abi.encode(rogue) - ); - vm.expectRevert(abi.encodeWithSelector(UnexpectedRklbAuthoriser.selector, rogue)); - script.run(); - } -} diff --git a/test/script/20260723-provision-additional-service-signer.prod.t.sol b/test/script/20260723-provision-additional-service-signer.prod.t.sol new file mode 100644 index 00000000..26d3c4b8 --- /dev/null +++ b/test/script/20260723-provision-additional-service-signer.prod.t.sol @@ -0,0 +1,96 @@ +// 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 { + ProvisionAdditionalServiceSigner, + AdditionalSignerAlreadyProvisioned +} from "../../script/20260723-provision-additional-service-signer.s.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../../src/generated/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibStoxDeployNetworks} from "../../src/lib/LibStoxDeployNetworks.sol"; + +/// @title ProvisionAdditionalServiceSignerProdTest +/// @notice PROD coverage for the additional-service-signer provisioning: +/// what production IS on each chain carrying a live V4 authoriser, read +/// from a real fork with no mocks anywhere. +/// +/// The provisioning has EXECUTED on both chains — the Safe signed it — so +/// each chain's authoriser holds all three canonical pairs and the script +/// refuses to author an empty bundle there. That refusal is the assertion: +/// a chain that stops holding a pair (a revoke, a swapped authoriser) goes +/// red here, because `run()` would find work to do again. +/// +/// @dev No mocks. Each chain's state is whatever the chain says it is; a +/// mocked fork fixture is a premise the next legitimate operation deletes, +/// which is precisely what retired the mocked predecessors of this file. +/// Selection logic — which pairs get authored for a given role state — is +/// covered without a fork in +/// `20260723-provision-additional-service-signer.t.sol`. +/// +/// The shared `out/` artifact path is not a constraint here: `run()` +/// reverts during pre-flight on a provisioned chain, so no test in this +/// file reaches the artifact write. +/// +/// The whole-map sweep (every `expectedGrants()` row, plus the negative +/// `DEFAULT_ADMIN_ROLE` assertion over the pinned grantees) already runs +/// per chain in `test/src/concrete/deploy/StoxCrossChainParity.t.sol` and +/// is deliberately not duplicated here; this file asserts the slice this +/// script owns. +contract ProvisionAdditionalServiceSignerProdTest is Test { + /// @notice The signer this operation provisions. + address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C; + + /// @notice Assert the ACTIVE fork is fully provisioned for the + /// additional signer: every canonical pair of the signer holds on the + /// chain's authoriser, and the script refuses to author against it. + /// @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 assertActiveForkProvisioned(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, ": additional signer is missing a canonical pair") + ); + } + assertEq(pairs, 3, "the canonical map carries the signer's three rows"); + + // Nothing left to author on this chain. `run()` reaches the + // refusal 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. + ProvisionAdditionalServiceSigner script = new ProvisionAdditionalServiceSigner(); + vm.expectRevert(AdditionalSignerAlreadyProvisioned.selector); + script.run(); + } + + /// @notice Base is provisioned. + function testBaseIsProvisioned() external { + vm.createSelectFork(LibRainDeploy.BASE); + assertActiveForkProvisioned( + "Base", LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE + ); + } + + /// @notice Ethereum is provisioned. + function testEthereumIsProvisioned() external { + vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM); + assertActiveForkProvisioned( + "Ethereum", + LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_ETHEREUM, + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_ETHEREUM + ); + } +} diff --git a/test/script/20260723-provision-additional-service-signer.t.sol b/test/script/20260723-provision-additional-service-signer.t.sol new file mode 100644 index 00000000..6ea03248 --- /dev/null +++ b/test/script/20260723-provision-additional-service-signer.t.sol @@ -0,0 +1,192 @@ +// 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 {AdditionalSignerAlreadyProvisioned} from "../../script/20260723-provision-additional-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 {ProvisionAdditionalServiceSignerHarness} from "./ProvisionAdditionalServiceSignerHarness.sol"; + +/// @title ProvisionAdditionalServiceSignerTest +/// @notice UNIT coverage for the additional-service-signer provisioning +/// 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` 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 — which is exactly what happened to the mocked fork +/// fixtures these replace. +/// +/// 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 +/// `20260723-provision-additional-service-signer.prod.t.sol`. +contract ProvisionAdditionalServiceSignerTest is Test { + /// @notice The signer being provisioned — the canonical pin the script + /// scopes itself to. + address internal constant SIGNER = LibAuthoriserInvariants.GRANTEE_SERVICE_3D0C; + + /// @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 + /// EXCEPT the additional signer's three — the pre-provisioning state, + /// i.e. the state the script was written against. `initialize` + /// auto-grants `SAFE` the seven `_ADMIN` roles; the remaining + /// non-signer 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 deployPreProvisionedAuthoriser() 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 (all[i].grantee == SIGNER) continue; + if (acl.hasRole(all[i].role, all[i].grantee)) continue; + vm.prank(SAFE); + acl.grantRole(all[i].role, all[i].grantee); + } + } + + /// @notice Really grant `role` to the additional signer, as the Safe. + /// @param authoriser The clone to grant on. + /// @param role The action role to grant. + function grantToSigner(address authoriser, bytes32 role) internal { + vm.prank(SAFE); + IAccessControl(authoriser).grantRole(role, SIGNER); + } + + /// @notice Assert the returned canonical pairs are exactly the 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 signer's rows rather than another + /// grantee's. Invariant across every state, because what the signer + /// already holds changes the BUNDLE, never the map. + /// @param additional The pairs returned alongside the bundle. + function assertCanonicalPairs(RoleGrant[] memory additional) internal pure { + assertEq(additional.length, 3, "the canonical map carries the signer's three rows"); + bytes32[3] memory roles = [DEPOSIT, WITHDRAW, CERTIFY]; + for (uint256 i = 0; i < roles.length; i++) { + assertEq(additional[i].role, roles[i], "canonical pair role"); + assertEq(additional[i].grantee, SIGNER, "canonical pair grantee"); + } + } + + /// @notice Assert `txn` is the `grantRole(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 assertGrantsRole(SafeTx memory txn, address authoriser, bytes32 role) internal pure { + assertEq(txn.to, authoriser, "grant tx must target the authoriser"); + assertEq(txn.value, 0, "grant tx must carry no value"); + assertEq(uint256(txn.operation), 0, "grant tx must be a CALL"); + assertEq(txn.data, abi.encodeCall(IAccessControl.grantRole, (role, SIGNER)), "grant tx calldata"); + } + + /// @notice Nothing provisioned: all three canonical pairs are authored, + /// in canonical map order. + function testAuthorsEveryPairWhenNothingIsProvisioned() external { + address authoriser = deployPreProvisionedAuthoriser(); + ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); + + (RoleGrant[] memory additional, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(additional); + assertEq(txs.length, 3, "every pair is missing, so every pair is authored"); + assertGrantsRole(txs[0], authoriser, DEPOSIT); + assertGrantsRole(txs[1], authoriser, WITHDRAW); + assertGrantsRole(txs[2], authoriser, CERTIFY); + } + + /// @notice Partial execution recovers by re-dispatch: with CERTIFY + /// really held, only DEPOSIT and WITHDRAW are authored. + function testAuthorsOnlyTheMissingPairsWhenCertifyIsHeld() external { + address authoriser = deployPreProvisionedAuthoriser(); + grantToSigner(authoriser, CERTIFY); + ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); + + (RoleGrant[] memory additional, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(additional); + assertEq(txs.length, 2, "the held pair is not re-authored"); + assertGrantsRole(txs[0], authoriser, DEPOSIT); + assertGrantsRole(txs[1], authoriser, WITHDRAW); + } + + /// @notice The self-scoping tracks WHICH pair is held, not merely how + /// many: with the FIRST canonical pair held the remaining two are + /// authored, so the bundle cannot be a prefix of the map. + function testAuthorsOnlyTheMissingPairsWhenDepositIsHeld() external { + address authoriser = deployPreProvisionedAuthoriser(); + grantToSigner(authoriser, DEPOSIT); + ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); + + (RoleGrant[] memory additional, SafeTx[] memory txs) = harness.callAuthorBundle(authoriser, SAFE); + + assertCanonicalPairs(additional); + assertEq(txs.length, 2, "the held pair is not re-authored"); + assertGrantsRole(txs[0], authoriser, WITHDRAW); + assertGrantsRole(txs[1], authoriser, CERTIFY); + } + + /// @notice A fully provisioned authoriser refuses to author an empty + /// bundle — the post-execution state, reached here by really granting + /// all three pairs. + function testRevertsWhenEveryPairIsAlreadyHeld() external { + address authoriser = deployPreProvisionedAuthoriser(); + grantToSigner(authoriser, DEPOSIT); + grantToSigner(authoriser, WITHDRAW); + grantToSigner(authoriser, CERTIFY); + ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); + + vm.expectRevert(AdditionalSignerAlreadyProvisioned.selector); + harness.callAuthorBundle(authoriser, SAFE); + } + + /// @notice The signer is never provisioned onto an authoriser whose + /// configuration has drifted: a missing NON-signer row aborts the + /// authoring before any pair is selected. + function testRevertsWhenTheGrantMapHasDrifted() external { + address authoriser = deployPreProvisionedAuthoriser(); + vm.prank(SAFE); + IAccessControl(authoriser).revokeRole(DEPOSIT, LibAuthoriserInvariants.GRANTEE_SERVICE_1C66); + ProvisionAdditionalServiceSignerHarness harness = new ProvisionAdditionalServiceSignerHarness(); + + vm.expectRevert(bytes("ProvisionAdditionalServiceSigner: authoriser grant map has drifted")); + harness.callAuthorBundle(authoriser, SAFE); + } +} diff --git a/test/script/ProvisionAdditionalServiceSignerHarness.sol b/test/script/ProvisionAdditionalServiceSignerHarness.sol new file mode 100644 index 00000000..0a3d8353 --- /dev/null +++ b/test/script/ProvisionAdditionalServiceSignerHarness.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 {ProvisionAdditionalServiceSigner} from "../../script/20260723-provision-additional-service-signer.s.sol"; +import {RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {SafeTx} from "../../src/lib/LibSafeOps.sol"; + +/// @title ProvisionAdditionalServiceSignerHarness +/// @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/DeployMissingTokensEthereumHarness.sol`. +contract ProvisionAdditionalServiceSignerHarness is ProvisionAdditionalServiceSigner { + /// @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 additional The signer's canonical pairs, in map order. + /// @return txs One `grantRole` tx per pair the signer does not hold. + function callAuthorBundle(address authoriser, address safeAddr) + external + view + returns (RoleGrant[] memory additional, SafeTx[] memory txs) + { + return authorBundle(authoriser, safeAddr); + } +}