diff --git a/.github/workflows/manual-broadcast.yaml b/.github/workflows/manual-broadcast.yaml new file mode 100644 index 00000000..9ccfa8fd --- /dev/null +++ b/.github/workflows/manual-broadcast.yaml @@ -0,0 +1,76 @@ +name: manual-broadcast +on: + workflow_dispatch: + inputs: + script: + description: 'Broadcast script to dispatch (broadcasts as the CI deploy key)' + required: true + type: choice + options: + # Append-only registry: add new entries at the bottom; never reorder + # or delete. Re-dispatching a historical (executed) script must + # remain possible — a signer/auditor may want to re-run its + # pre-flight against on-chain reality to confirm what landed. + # + # Each entry is the date-prefixed filename (without `.s.sol`) of a + # broadcast script under `script/`. Convention: + # `YYYYMMDD-`, where the date is the day the script was + # added to this dropdown. Execution status (PENDING / EXECUTED) + # lives in the script's file-level NatSpec — this dropdown is a + # registry of *which* scripts exist, not *whether* they've run. + - 20260619-deploy-v4-authoriser-clone +# Dispatches an operational broadcast script from `script/` and sends the +# resulting transactions from the CI deploy key (`secrets.PRIVATE_KEY`, +# the same secret `manual-sol-artifacts.yaml` uses for Zoltu impl deploys). +# +# When to use this dispatcher vs. run-script.yaml: +# - `run-script.yaml` is for scripts that emit off-chain artifacts +# (Safe Tx Builder JSON) for a Safe multisig to sign later. Runs +# without `--broadcast`. +# - THIS workflow is for scripts that broadcast on-chain directly from +# the deploy key — same key that runs `manual-sol-artifacts.yaml` for +# impl deploys. Runs with `--broadcast`. +# +# The `slow` flag makes forge wait for each tx to confirm before sending +# the next; without it a script that lands a sequence of dependent txs +# (e.g. deploy → grantRole using the deploy) can race the nonce. +jobs: + broadcast: + name: Broadcast operational script + runs-on: ubuntu-latest + # Least-privilege: the job only checks out the repo and runs forge; the + # on-chain broadcast authenticates via `secrets.PRIVATE_KEY`, so the + # ambient GITHUB_TOKEN needs nothing beyond read. + permissions: + contents: read + # Serialise dispatches of the same script so overlapping runs don't + # race on the same nonce / pre-flight state. + concurrency: + group: manual-broadcast-${{ inputs.script }} + cancel-in-progress: false + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: DeterminateSystems/nix-installer-action@21a544727d0c62386e78b4befe52d19ad12692e3 # v17 + - name: Install Soldeer dependencies + run: nix develop --command forge soldeer install + - name: Broadcast script + env: + BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }} + # Pass the choice input via env rather than template-expanding it + # into the command, so the dispatched script name is used as a + # literal argument and cannot inject shell. + SCRIPT: ${{ inputs.script }} + # PRIVATE_KEY is only available inside this step — matching + # `manual-sol-artifacts.yaml`. The workflow file itself does not + # persist it anywhere else. + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + run: | + nix develop --command forge script "script/${SCRIPT}.s.sol" \ + --sig 'run()' \ + --rpc-url base \ + --no-storage-caching \ + --slow \ + --broadcast \ + --private-key "${PRIVATE_KEY}" diff --git a/.github/workflows/run-script.yaml b/.github/workflows/run-script.yaml index 4aa484b2..be5a7845 100644 --- a/.github/workflows/run-script.yaml +++ b/.github/workflows/run-script.yaml @@ -13,11 +13,18 @@ on: # bundle to verify what landed on-chain. # # Each entry is the date-prefixed filename (without `.s.sol`) of a - # script under `script/`. Convention: `YYYYMMDD-`, - # where the date is the day the script was added to this dropdown. - # Execution status (PENDING / EXECUTED + SafeTxHash) lives in the - # script's file-level NatSpec — this dropdown is a registry of - # *which* scripts exist, not *whether* they've run. + # Safe Tx Builder JSON emitting script under `script/`. Convention: + # `YYYYMMDD-`, where the date is the day the script was + # added to this dropdown. Execution status (PENDING / EXECUTED + + # SafeTxHash) lives in the script's file-level NatSpec — this + # dropdown is a registry of *which* scripts exist, not *whether* + # they've run. + # + # Broadcast scripts (dispatched with `--broadcast` from the CI + # deploy key) live in `manual-broadcast.yaml` instead. The + # `20260619-deploy-v4-authoriser-clone` entry stays listed here + # too, but dispatching via run-script.yaml is DRY-RUN ONLY (no + # `--broadcast`) — useful as a pre-flight smoke test. - 20260619-deploy-v4-authoriser-clone sig: description: 'Entrypoint to dispatch (default: run())' @@ -34,7 +41,6 @@ on: # JSON path argument this dispatcher can't supply and runs off-chain # on the signer's machine, not in CI. - 'run()' - - 'mirrorGrants()' # Manually dispatches an operational script from `script/` and uploads any # JSON it writes to `out/` as a build artifact. # diff --git a/script/20260619-deploy-v4-authoriser-clone.s.sol b/script/20260619-deploy-v4-authoriser-clone.s.sol index 27052fc4..6727b339 100644 --- a/script/20260619-deploy-v4-authoriser-clone.s.sol +++ b/script/20260619-deploy-v4-authoriser-clone.s.sol @@ -4,605 +4,307 @@ 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 {Vm} from "forge-std-1.16.1/src/Vm.sol"; import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; - -import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; -import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol"; -import {LibProdDeployV4} from "../src/lib/LibProdDeployV4.sol"; +import {ERC1167_PREFIX, ERC1167_SUFFIX} from "rain-extrospection-0.1.1/src/lib/LibExtrospectERC1167Proxy.sol"; import {ICloneableFactoryV2} from "rain-factory-0.1.1/src/interface/ICloneableFactoryV2.sol"; import {LibCloneFactoryDeploy} from "rain-factory-0.1.1/src/lib/LibCloneFactoryDeploy.sol"; import { OffchainAssetReceiptVaultAuthorizerV1Config } from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; -import {ERC1167_PREFIX, ERC1167_SUFFIX} from "rain-extrospection-0.1.1/src/lib/LibExtrospectERC1167Proxy.sol"; -/// @notice Pre-flight failed: the pinned V4 authoriser impl in -/// `LibProdDeployV4` has no runtime code at its pinned address. Surfaces -/// the impl address that's missing so the operator knows which Zoltu -/// deploy is still pending. -/// @param impl The expected V4 impl address (the pin in `LibProdDeployV4`). +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../src/lib/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; + +/// @notice The V4 authoriser impl at +/// `LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1` +/// has no runtime code. Either the pin is stale or the impl has been +/// selfdestructed since the pin was written; either way the clone would +/// initialise against zero code. error V4ImplNotDeployed(address impl); -/// @notice Pre-flight failed: the runtime codehash at the pinned V4 impl -/// address does not match the pinned codehash. Signals either that a -/// non-canonical contract is squatting the address or that the Zoltu -/// deploy emitted different bytecode than the lib expects. -/// @param impl The V4 impl address inspected. -/// @param expected The pinned codehash (`STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_CODEHASH_0_1_1`). -/// @param actual The codehash observed at `impl`. +/// @notice The V4 authoriser impl's runtime codehash does not match the pinned +/// value in `LibProdDeployV4`. Impl has been replaced with different code. error V4ImplCodehashMismatch(address impl, bytes32 expected, bytes32 actual); -/// @notice Pre-flight failed: the canonical Rain `CloneFactory` at -/// `LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS` has no runtime -/// code on the active fork. The clone-deploy bundle targets this address -/// and would revert in production, so emitting the artifact would be -/// pointless. -/// @param factory The expected canonical CloneFactory address. +/// @notice The canonical `CloneFactory` from `rain-factory-0.1.1` is not +/// deployed at its pinned address. Zoltu deploy is missing on this network. error CloneFactoryNotDeployed(address factory); -/// @notice Pre-flight failed: the runtime codehash at the canonical -/// CloneFactory address does not match the pinned -/// `LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_CODEHASH`. -/// @param factory The CloneFactory address inspected. -/// @param expected The pinned codehash. -/// @param actual The codehash observed at `factory`. +/// @notice The `CloneFactory` runtime codehash does not match the rain-factory +/// pin. The address at the pinned location is not the audited factory. error CloneFactoryCodehashMismatch(address factory, bytes32 expected, bytes32 actual); -/// @notice Pre-flight failed (deploy branch only): the simulated clone's -/// runtime codehash does not match the EIP-1167 minimal-proxy runtime -/// computed from the pinned V4 impl literal. Signals either that the -/// emitted clone has been etched over or that the V4 impl pin and the -/// runtime computation drifted apart. -/// @param clone The clone address observed in the `NewClone` event. -/// @param expected The EIP-1167 minimal-proxy runtime codehash computed -/// from the V4 impl literal. -/// @param actual The codehash observed at `clone`. +/// @notice The `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` pin is already +/// hydrated. This script deploys a NEW clone — running it a second time would +/// produce a second clone the lib pin does not know about. Once hydrated, the +/// script is done for that chain. +error V4AuthoriserClonePinAlreadyHydrated(address pinned); + +/// @notice The freshly-deployed clone's runtime codehash does not match the +/// EIP-1167 minimal-proxy shape computed from the V4 impl. Either the factory +/// deployed something other than an EIP-1167 clone, or the impl embedded in +/// the proxy is not the pinned V4 impl. error CloneCodehashMismatch(address clone, bytes32 expected, bytes32 actual); -/// @notice Pre-flight failed (grants branch only): the V4 authoriser -/// clone constant in `LibProdDeployV4` is still the `address(0)` -/// placeholder. The clone must be deployed (and its address dropped -/// into the lib by the post-execution hydrate PR) before the mirror -/// bundle can be authored — the typed revert is the explicit -/// forcing-function that blocks a grants bundle pointing at an -/// arbitrary operator-supplied address. -error V4AuthoriserCloneNotPinned(); - -/// @notice Pre-flight failed (grants branch only): the lib-pinned V4 -/// authoriser clone address is non-zero but has no runtime code. Either -/// the hydrate PR landed before the deploy bundle executed on Base or -/// the pinned address was wrong. -/// @param clone The pinned clone address that has no code. -error V4AuthoriserCloneNotDeployed(address clone); - -/// @notice Pre-flight failed (grants branch only): the lib-pinned V4 -/// authoriser clone's runtime codehash does not match -/// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH`. Signals -/// either that the clone has been etched over since the hydrate PR -/// merged or that the hydrate PR pinned the wrong literal. -/// @param clone The pinned clone address inspected. -/// @param expected The pinned clone codehash. -/// @param actual The codehash observed on-chain. -error V4AuthoriserCloneCodehashMismatch(address clone, bytes32 expected, bytes32 actual); - -/// @notice Pre-flight failed: a role grant that the base authoriser's -/// `initialize` is expected to make automatically against the Safe is -/// missing on the clone. Surfaces the exact role + grantee that broke -/// the invariant. -/// @param clone The clone address inspected. -/// @param role The auto-grant role that should be held. -/// @param grantee The expected grantee (the ST0x token-owner Safe). -error AutoGrantMissing(address clone, bytes32 role, address grantee); - -/// @notice Pre-flight failed (deploy branch only): a non-admin grant -/// that this script is supposed to mirror in is already held on a fresh -/// clone, before the mirror bundle has been authored. Either the clone -/// is not fresh or `LibAuthoriserInvariants.expectedGrants()` is wrong -/// about which grants the base `initialize` makes. -/// @param clone The clone address inspected. -/// @param role The non-admin role found to be unexpectedly held. -/// @param grantee The grantee that holds the role. -error UnexpectedAutoGrantHeld(address clone, bytes32 role, address grantee); - -/// @notice A previously emitted Tx Builder JSON artifact (parsed via -/// `LibSafeOps.parseTxBuilderJson`) does not match the bundle the live -/// pre-flight would emit. Surfaces the first field that drifts so a -/// signer can pinpoint where the off-chain artifact diverged from the -/// on-chain state at verification time. -/// @param field The name of the field that drifted (e.g. `"chainId"`, -/// `"to"`, `"data"`, `"safeTxHash"`, `"txCount"`). -error VerifyMismatch(string field); - -/// @notice `verify()` could not decide which bundle (deploy or grants) -/// the supplied artifact represents from its tx count. The deploy bundle -/// is a single tx; the grants bundle is exactly 6 txs. Any other count -/// is unambiguous drift rather than a future-proofing exercise. -/// @param actualCount The number of transactions in the parsed artifact. -error VerifyUnknownBundleShape(uint256 actualCount); - -/// @notice `LibAuthoriserInvariants.expectedGrants()` no longer has the length -/// the hand-maintained non-admin slice (`MIRROR_START_INDEX` .. -/// `MIRROR_START_INDEX + GRANTS_TX_COUNT`) assumes, so the mirror would -/// silently truncate or mis-select the grants it authors. Forces a lib reshape -/// to fail fast here rather than as an out-of-bounds panic downstream. -/// @param actual The current `expectedGrants()` length. -/// @param expected The length the slice constants assume. -error GrantsSliceLengthDrift(uint256 actual, uint256 expected); +/// @notice A `(role, grantee)` pair that `LibAuthoriserInvariants. +/// expectedGrants()` says must hold is missing on the freshly-configured +/// clone. Either the grantRole loop skipped it or a subsequent renounce +/// removed it. +error ExpectedGrantMissing(bytes32 role, address grantee); + +/// @notice The broadcasting deployer key still holds an `_ADMIN` role after +/// the renounce loop. If it stayed put the deployer keeps root privileges +/// over that role's grant map — the exact escalation this transfer is +/// designed to close. +error DeployerStillHoldsAdminRole(bytes32 role, address deployer); + +/// @notice `MIRROR_START_INDEX` is out of range for the +/// `LibAuthoriserInvariants.expectedGrants()` array. The hand-maintained +/// slice constants have drifted from the invariant lib. +error GrantsSliceOutOfRange(uint256 startIndex, uint256 sliceLength, uint256 gramGrantsLen); /// @title DeployV4AuthoriserClone -/// @notice Forge script that authors the V4 authoriser clone deploy + -/// the forward-mirror of the live non-admin role grants onto the new -/// clone, as two separate Safe Tx Builder JSON artifacts ready for the -/// ST0x token-owner Safe to sign and execute. -/// -/// Two artifacts because `Clones.clone()` is non-deterministic -/// (nonce-based, no CREATE2 salt) — the clone's address isn't known -/// until the first bundle lands. The pattern is therefore: +/// @notice Broadcast script that: /// -/// 1. `run()` — authors the clone-deploy bundle (target = the canonical -/// Rain `CloneFactory`, calldata = `clone(v4Impl, abi.encode(Config(Safe)))`). -/// The base `OffchainAssetReceiptVaultAuthorizerV1.initialize` plus -/// the ST0x override grants seven `_ADMIN` roles to the Safe -/// automatically: `CERTIFY_ADMIN`, `CONFISCATE_RECEIPT_ADMIN`, -/// `CONFISCATE_SHARES_ADMIN`, `DEPOSIT_ADMIN`, `WITHDRAW_ADMIN`, -/// `SCHEDULE_CORPORATE_ACTION_ADMIN`, `CANCEL_CORPORATE_ACTION_ADMIN`. -/// No further action is required to put the Safe in admin position; -/// the deploy bundle alone is enough to land the clone. +/// 1. Deploys a fresh V4 authoriser clone via `CloneFactory.clone`, +/// initialised with the deployer as `initialAdmin`. The SEVEN +/// `_ADMIN` roles the base + ST0x-override `initialize` auto-grant +/// (five base: CERTIFY / CONFISCATE_RECEIPT / CONFISCATE_SHARES / +/// DEPOSIT / WITHDRAW; two override: SCHEDULE_CORPORATE_ACTION / +/// CANCEL_CORPORATE_ACTION) therefore land on the deployer, not the +/// Safe. +/// 2. Grants the six non-admin roles enumerated in +/// `LibAuthoriserInvariants.expectedGrants()` (indices +/// `MIRROR_START_INDEX ..`) to their pinned grantees. These are the +/// operational `DEPOSIT` / `WITHDRAW` / `CERTIFY` provisions. +/// 3. Grants every auto-granted `_ADMIN` role (all seven) to the ST0x +/// token-owner Safe. This matches the shape the previous Safe-signed +/// flow produced (`initialAdmin = Safe` auto-granted all seven +/// directly), and keeps the corporate-action admin-holder question +/// (RAI-731) open rather than deciding it here by omission. +/// 4. Renounces every auto-granted `_ADMIN` role from the deployer. +/// Post-loop the Safe is sole admin; the deployer has no residual +/// power over the clone. /// -/// 2. After bundle 1 executes on Base, a separate human-reviewed PR -/// hydrates `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` from -/// `address(0)` to the literal clone address and -/// `STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH` from `bytes32(0)` to the -/// keccak256 of the EIP-1167 minimal-proxy runtime ("post-execution -/// pin" pattern). Until that PR merges, `mirrorGrants()` trips the -/// pre-flight rather than accept an arbitrary address. +/// All four steps run under a single `vm.startBroadcast()` — the deploy +/// key executes them in sequence in one `forge script --broadcast` +/// invocation. Dispatched via `.github/workflows/manual-broadcast.yaml`, +/// which broadcasts as `secrets.PRIVATE_KEY` — the same CI-held deploy +/// key `manual-sol-artifacts.yaml` uses for Zoltu impl deploys. The Safe +/// never signs anything for this deploy: the whole clone-configuration +/// ceremony collapses into a workflow-dispatch broadcast matching the +/// impl-deploy pattern the ops flow already uses. /// -/// 3. `mirrorGrants()` — reads the clone address from the lib pin, -/// refuses to proceed unless the pin is non-zero, has code, and the -/// pinned codehash matches; then authors a six-tx bundle that -/// `grantRole(role, grantee)`s the six non-admin entries from -/// `LibAuthoriserInvariants.expectedGrants()` (indices 5..10) onto -/// the clone. After this bundle lands the clone holds all 11 grants -/// enumerated in `expectedGrants()` plus the two extra corporate-action -/// admins (13 role grants in total), ready for `setAuthorizer` to swap -/// every receipt vault onto it. +/// @dev Trust model. During the four-step sequence the deployer key +/// holds every `_ADMIN` role and could self-grant additional operational +/// roles or extra `_ADMIN` positions. The post-state assertion at the +/// end of `run()` closes the "deployer still holds an admin role" case +/// (step 4 verifier), but does NOT enumerate for UNEXPECTED grants +/// beyond `expectedGrants()`. A compromised deploy key could sneak in a +/// stray `DEPOSIT` role for an attacker-controlled address between +/// steps 1 and 4 and this script would not catch it. /// -/// 4. `verify(jsonPath)` — re-runs the relevant pre-flight, parses the -/// artifact, and asserts the parsed bundle matches what the live -/// pre-flight would emit. Used by signers to confirm an artifact -/// wasn't tampered with between authoring and signing. The grants- -/// bundle branch sources the clone address from the same lib pin -/// `mirrorGrants()` reads, so the same hydrate-then-verify ordering -/// applies. -/// -/// The two-bundle separation also gives the Safe owners a natural -/// checkpoint between deploying the clone and mirroring grants: the -/// clone's address goes into the lib's constant before grants are -/// authored, so the grants bundle's targets cannot drift away from the -/// actually-deployed clone. +/// The V4 upgrade + swap script (`20260623-upgrade-receipt-vaults-to-v4. +/// s.sol`) is the enforcement point that must catch that: its pre-flight +/// asserts `LibAuthoriserInvariants.assertExpectedGrants(clone)` before +/// pointing any production vault at this clone, so an unexpected grant +/// would surface there. An exhaustive "no grants outside the expected +/// map" check on `LibAuthoriserInvariants` is planned as a follow-up +/// (top of the migration stack) and will close this gap regardless of +/// dispatch mechanism. contract DeployV4AuthoriserClone is Script { - /// @notice Human-readable name embedded in the deploy bundle's - /// `meta.name`. Visible to signers in the Safe Tx Builder UI. - string internal constant DEPLOY_BUNDLE_NAME = "ST0x V4 authoriser - deploy clone"; - - /// @notice Human-readable name embedded in the grants bundle's - /// `meta.name`. Visible to signers in the Safe Tx Builder UI. - string internal constant GRANTS_BUNDLE_NAME = "ST0x V4 authoriser - mirror non-admin grants"; - - /// @notice Output path (relative to the project root) for the deploy - /// bundle JSON artifact. - string internal constant DEPLOY_ARTIFACT_PATH = "out/v4-authoriser-clone-deploy.json"; - - /// @notice Output path (relative to the project root) for the grants - /// bundle JSON artifact. - string internal constant GRANTS_ARTIFACT_PATH = "out/v4-authoriser-clone-grants.json"; - - /// @notice The deploy bundle's tx count (one — a single - /// `CloneFactory.clone` call). Used by `verify` as the discriminator - /// against the grants bundle's tx count. - uint256 internal constant DEPLOY_TX_COUNT = 1; - - /// @notice The grants bundle's tx count. Six entries — the - /// non-admin slice (indices 5..10) of - /// `LibAuthoriserInvariants.expectedGrants()`. - uint256 internal constant GRANTS_TX_COUNT = 6; - /// @notice The starting index of the non-admin grant slice inside /// `LibAuthoriserInvariants.expectedGrants()`. Indices 0..4 are the /// V3-era `_ADMIN` grants which the base `initialize` auto-grants on - /// the V4 clone (plus the two corporate-action admins the override - /// adds), so this script never needs to mirror them. Indices 5..10 - /// are the operational grants (`DEPOSIT` / `WITHDRAW` / `CERTIFY` × - /// service + Safe) that must be hand-mirrored. + /// the freshly-cloned V4 authoriser. Indices 5..10 are the + /// operational grants (`DEPOSIT` / `WITHDRAW` / `CERTIFY` × service + + /// Safe) this script mirrors in. uint256 internal constant MIRROR_START_INDEX = 5; - /// @notice Resolve the V4 authoriser clone address by reading the - /// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` lib pin. - /// @dev Virtual so test scaffolding can subclass the script and - /// inject a simulated post-hydrate address without monkeying with - /// the library's bytecode constant (library constants live in - /// bytecode, not storage, so `vm.store` is not an option). All - /// production reads of the constant inside this script go through - /// this helper — never bypass it. - /// @return The pinned clone address, or `address(0)` while the - /// post-execution hydrate PR is still pending. - function _resolveClone() internal view virtual returns (address) { - return LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; - } - - /// @notice Hook invoked by `run()` after the deploy simulation has - /// produced a predicted clone address. No-op in production; test - /// scaffolding overrides this to stash the address so the grants- - /// bundle suite can pre-load `_resolveClone()` with the same value - /// `run()` simulated. - /// @dev Virtual rather than letting tests re-scan `vm.recordLogs()` - /// themselves because `run()` already consumes the recorded logs - /// via `vm.getRecordedLogs()`; a second outer `getRecordedLogs()` - /// call returns an empty array. - /// @param predictedClone The clone address the deploy simulation - /// predicted (the `NewClone` event's `clone` field). - function _recordPredictedClone(address predictedClone) internal virtual {} - - /// @notice Resolve the expected EIP-1167 codehash of the lib-pinned - /// V4 authoriser clone by reading - /// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH`. - /// @dev Virtual for the same reason as `_resolveClone()` — the lib - /// constant is `bytes32(0)` until the post-execution hydrate PR - /// merges, and tests need to simulate the post-hydrate value - /// without rewriting the library bytecode. Always read via this - /// helper rather than the lib constant directly so the testable - /// subclass's override applies uniformly. - /// @return The pinned codehash, or `bytes32(0)` while the post- - /// execution hydrate PR is still pending. - function _resolveCloneCodehash() internal view virtual returns (bytes32) { - return LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH; - } - - /// @notice Dry-run the V4 authoriser clone deploy: pre-flight every - /// invariant the bundle will rely on, simulate the clone, assert - /// the post-state matches the auto-grants the base + override - /// `initialize` are expected to make, emit the Tx Builder JSON - /// artifact, and log the canonical SafeTxHash + the predicted clone - /// address (between BEGIN/END markers so CI can grep them). - /// @dev Does not broadcast anything — the inner call is gated - /// behind the Safe's own signature verification in production and - /// we explicitly simulate via `vm.prank`. The simulated nonce on - /// the Safe is NOT advanced by `simulateExternalCall` so the - /// captured `safeTxHash` binds to the live current nonce. + /// @notice The number of non-admin grants this script mirrors in. + uint256 internal constant MIRROR_COUNT = 6; + + /// @notice The number of `_ADMIN` roles the base + ST0x-override + /// `initialize` auto-grant to `initialAdmin` (five base + two + /// corporate-action admins from the override). + uint256 internal constant AUTO_GRANTED_ADMIN_COUNT = 7; + + /// @notice Deploy + configure + admin-transfer the V4 authoriser clone + /// in a single broadcast. Steps 1-4 in the contract-level NatSpec. + /// Pre-flight covers the invariants the whole flow relies on: the + /// Safe is intact, the V4 impl exists at the pin with the pinned + /// codehash, the CloneFactory is deployed with its pinned codehash, + /// and the clone pin is not already hydrated (this would be a + /// second deploy on the same network). function run() external { IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - // Pre-flight: Safe immutable invariants + pinned owner set + - // pinned threshold. Reverts with the relevant typed error from - // `LibSafeInvariants` on first mismatch. + // Pre-flight: Safe still matches the pinned owners + threshold + + // immutables. If the Safe has drifted, admin transfer would + // move power to a shape we no longer recognise. LibSafeInvariants.assertAll(safe); - // Pre-flight: the invariant map still matches the hand-maintained - // non-admin slice constants. + // Pre-flight: the invariant map still lines up with the hand- + // maintained slice constants. assertGrantsSliceInvariant(); // Pre-flight: V4 impl deployed at the pinned address with the // pinned codehash. The clone will EIP-1167-proxy this address; // if it isn't there or has the wrong code, the clone would - // either fail to initialize or initialize against attacker - // code. + // either fail to initialise or initialise against attacker code. address v4Impl = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; assertV4ImplDeployed(v4Impl); - // Pre-flight: the canonical CloneFactory is deployed with the - // pinned codehash. This is the only target of the bundle, so a - // missing factory means the bundle would revert in production. + // Pre-flight: the canonical `CloneFactory` is deployed with the + // pinned codehash. A missing/replaced factory would either + // revert or hand back a clone under attacker-supplied bytecode. address factoryAddr = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; assertCloneFactoryDeployed(factoryAddr); - // Build the single-tx bundle: target = CloneFactory, calldata = - // `clone(v4Impl, abi.encode(Config(Safe)))`. - bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: address(safe)})); - SafeTx memory txn = SafeTx({ - to: factoryAddr, value: 0, data: abi.encodeCall(ICloneableFactoryV2.clone, (v4Impl, initData)), operation: 0 - }); - - // Capture the nonce before any simulation. `simulateExternalCall` - // does not advance the nonce, so the hash binds to the current - // Safe state. - uint256 nonce = safe.nonce(); - bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txn, nonce); - - // Simulate the inner call via `vm.prank(safe)` -> CloneFactory, - // recording logs so we can fish the predicted clone address out - // of the `NewClone` event. The Safe nonce is intentionally NOT - // advanced by `simulateExternalCall`. - vm.recordLogs(); - LibSafeOps.simulateExternalCall(safe, factoryAddr, txn.data); - address predictedClone = extractCloneAddressFromLogs(vm.getRecordedLogs(), factoryAddr, v4Impl); - - // Assert the simulated clone's runtime codehash matches the - // EIP-1167 minimal-proxy runtime computed from the V4 impl - // literal. The expected codehash is the same one that - // `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH` will - // eventually pin once the literal is hydrated post-deploy. - bytes32 expectedCloneCodehash = computeMinimalProxyCodehash(v4Impl); - assertCloneCodehash(predictedClone, expectedCloneCodehash); - - // Assert the seven auto-grants the base + override `initialize` - // produce are actually held by the Safe, and that the six - // non-admin grants this script is about to author are NOT yet - // held on the fresh clone (so the mirror bundle is genuinely - // adding new state, not no-oping). - assertAutoGrantsHeld(predictedClone, address(safe)); - assertNonAdminGrantsAbsent(predictedClone); - - // Emit the Tx Builder JSON artifact and write it under `out/`. - SafeTx[] memory txs = new SafeTx[](DEPLOY_TX_COUNT); - txs[0] = txn; - string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, DEPLOY_BUNDLE_NAME, txs); - vm.writeFile(DEPLOY_ARTIFACT_PATH, json); - - // Log the artifact with explicit BEGIN/END markers so CI can - // grep the bundle from the run log even when the JSON has been - // pretty-printed by an intermediate tool. The predicted clone - // address is logged separately so the operator can hydrate - // `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` with it post- - // execution. - console2.log("==== TX BUILDER JSON BEGIN ===="); - console2.log(json); - console2.log("==== TX BUILDER JSON END ===="); - console2.log("SafeTxHash:", vm.toString(safeTxHash)); - console2.log("Nonce:", nonce); - console2.log("PredictedClone:", vm.toString(predictedClone)); - console2.log("ExpectedCloneCodehash:", vm.toString(expectedCloneCodehash)); - - // Hook for test scaffolding. No-op in production. - _recordPredictedClone(predictedClone); - } + // Pre-flight: the clone pin is not already hydrated. If it is, + // running this script would deploy a SECOND clone the lib + // doesn't know about — same behaviour as re-running any + // deterministic deploy after it has already landed. + address pinned = LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE; + if (pinned != address(0)) revert V4AuthoriserClonePinAlreadyHydrated(pinned); - /// @notice Dry-run the V4 authoriser non-admin grant mirror: pre- - /// flight the Safe + the lib-pinned clone, build the six-tx grants - /// bundle, simulate each `grantRole` call via `vm.prank(safe)`, - /// assert the full 11-entry `expectedGrants()` map plus the two - /// auto-granted corporate-action admins all hold on the clone post- - /// state, emit the Tx Builder JSON artifact, and log the canonical - /// SafeTxHash. The bundle targets the resolved clone six times (one - /// `grantRole` per non-admin entry). - /// @dev The clone address is read from - /// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` via - /// `_resolveClone()` rather than taken as a parameter so that until - /// the post-execution hydration PR lands the pre-flight reverts - /// with `V4AuthoriserCloneNotPinned()` instead of accepting an - /// arbitrary operator-supplied address. Three pre-flight checks - /// gate the bundle: (1) the lib constant is non-zero (the hydrate - /// PR has merged), (2) the resolved address has runtime code (the - /// deploy bundle has executed on Base), and (3) the resolved - /// address's codehash matches the lib-pinned codehash (the hydrate - /// PR pinned the right literal). Together these guarantee the - /// grants bundle cannot drift onto a wrong-shaped or attacker- - /// supplied target. - function mirrorGrants() external { - IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); - // Pre-flight: Safe immutable invariants + pinned owner set + - // pinned threshold. - LibSafeInvariants.assertAll(safe); + vm.startBroadcast(); - // Pre-flight: the invariant map still matches the hand-maintained - // non-admin slice constants. - assertGrantsSliceInvariant(); + // Deployer identity — inside `vm.startBroadcast()` msg.sender + // resolves to the broadcast address (from + // `--private-key`/`--sender` in production). Captured here so + // subsequent grants + renounces line up with the initialAdmin + // baked into the clone's initialize call. + address deployer = msg.sender; - // Pre-flight: read the clone from the lib pin and trip - // typed-error reverts if any of the three forcing-function - // invariants is broken (pin still `address(0)`, pin has no - // runtime code, pin's codehash drifts from the lib). - address clone = _resolveClone(); - if (clone == address(0)) revert V4AuthoriserCloneNotPinned(); - if (clone.code.length == 0) revert V4AuthoriserCloneNotDeployed(clone); - bytes32 expectedCloneCodehash = _resolveCloneCodehash(); - bytes32 actualCloneCodehash = clone.codehash; - if (actualCloneCodehash != expectedCloneCodehash) { - revert V4AuthoriserCloneCodehashMismatch(clone, expectedCloneCodehash, actualCloneCodehash); - } + // Step 1: deploy the clone. + // + // `initialAdmin = deployer` means the seven `_ADMIN` auto-grants + // land on `deployer` in this window. Steps 3-4 swap them onto + // the Safe. + bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: deployer})); + address clone = ICloneableFactoryV2(factoryAddr).clone(v4Impl, initData); - // Pre-flight: the clone already holds the seven auto-grants the - // base + override `initialize` should have made during deploy. - // If any of those is missing the deploy bundle either failed or - // initialised against a different admin. - assertAutoGrantsHeld(clone, address(safe)); + IAccessControl acl = IAccessControl(clone); - // Build the N-tx bundle: one `grantRole(role, grantee)` per - // non-admin entry in `expectedGrants()` (indices 5..10). - RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); - SafeTx[] memory txs = new SafeTx[](GRANTS_TX_COUNT); - for (uint256 i = 0; i < GRANTS_TX_COUNT; i++) { + // Step 2: mirror the six non-admin operational grants + // (`DEPOSIT` / `WITHDRAW` / `CERTIFY` × service + Safe). + for (uint256 i = 0; i < MIRROR_COUNT; i++) { RoleGrant memory grant = allGrants[MIRROR_START_INDEX + i]; - txs[i] = SafeTx({ - to: clone, - value: 0, - data: abi.encodeCall(IAccessControl.grantRole, (grant.role, grant.grantee)), - operation: 0 - }); + acl.grantRole(grant.role, grant.grantee); } - // Capture the nonce before simulation. - uint256 nonce = safe.nonce(); - bytes32 safeTxHash = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); - - // Simulate each `grantRole` call via `vm.prank(safe)`. The Safe - // nonce is intentionally NOT advanced by `simulateExternalCall`. - for (uint256 i = 0; i < GRANTS_TX_COUNT; i++) { - LibSafeOps.simulateExternalCall(safe, txs[i].to, txs[i].data); + // Step 3: grant each auto-granted `_ADMIN` role to the Safe — + // all SEVEN (the five V3-era admins in `expectedGrants()[0..4]` + // plus the two corporate-action admins only the V4 override + // grants; the lib map doesn't carry those two yet). After this + // loop both `deployer` and `safe` hold every `_ADMIN` role; + // step 4 revokes the deployer's copy. + bytes32[AUTO_GRANTED_ADMIN_COUNT] memory adminRoles = autoGrantedAdminRoles(); + for (uint256 i = 0; i < adminRoles.length; i++) { + acl.grantRole(adminRoles[i], address(safe)); } - // Post-state: the full `expectedGrants()` map holds on the - // clone. This re-checks the seven auto-grants AND the six just- - // simulated mirror grants in one sweep, so any drift between - // the bundle and the lib's invariant surfaces here. - LibAuthoriserInvariants.assertExpectedGrants(clone); - - // Emit the Tx Builder JSON artifact and write it under `out/`. - string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, GRANTS_BUNDLE_NAME, txs); - vm.writeFile(GRANTS_ARTIFACT_PATH, json); - - console2.log("==== TX BUILDER JSON BEGIN ===="); - console2.log(json); - console2.log("==== TX BUILDER JSON END ===="); - console2.log("SafeTxHash:", vm.toString(safeTxHash)); - console2.log("Nonce:", nonce); - console2.log("Clone:", vm.toString(clone)); - } - - /// @notice Re-runs the relevant pre-flight and asserts that a pre- - /// emitted Tx Builder JSON at `jsonPath` matches what the live - /// pre-flight would emit. Discriminates the deploy bundle from the - /// grants bundle by tx count. - /// @dev The grants-bundle branch sources the clone address from - /// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` (via - /// `_resolveClone()`) and applies the same three forcing-function - /// pre-flight checks as `mirrorGrants()`. The deploy-bundle branch - /// does not need a clone address (the deploy bundle is authored - /// before the clone exists). - /// @param jsonPath Filesystem path to the Tx Builder JSON to - /// verify. - function verify(string calldata jsonPath) external view { - IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - LibSafeInvariants.assertAll(safe); - - (uint256 parsedChainId, address parsedTo, SafeTx[] memory parsedTxs) = LibSafeOps.parseTxBuilderJson(jsonPath); - - if (parsedChainId != block.chainid) revert VerifyMismatch("chainId"); - - if (parsedTxs.length == DEPLOY_TX_COUNT) { - verifyDeployBundle(safe, parsedTo, parsedTxs); - } else if (parsedTxs.length == GRANTS_TX_COUNT) { - verifyGrantsBundle(safe, parsedTo, parsedTxs); - } else { - revert VerifyUnknownBundleShape(parsedTxs.length); + // Step 4: renounce each auto-granted `_ADMIN` role from the + // deployer. `renounceRole` requires `msg.sender == account`, + // which holds because we are broadcasting as `deployer`. + for (uint256 i = 0; i < adminRoles.length; i++) { + acl.renounceRole(adminRoles[i], deployer); } - } - - /// @notice Deploy-bundle verify branch. Re-checks the V4 impl pin, - /// the CloneFactory pin, asserts the parsed tx targets the factory - /// with the canonical `clone(v4Impl, abi.encode(Config(Safe)))` - /// calldata, and cross-checks the implied SafeTxHash against the - /// live Safe's hash builder. - /// @dev Note `parsedTo` from the artifact is the first tx's `to` - /// (the canonical CloneFactory address), not the Safe. The first - /// tx in the deploy bundle does not target the Safe, so the - /// parsedTo check uses the factory address rather than the Safe. - /// @param safe The live Safe handle. - /// @param parsedTo The `transactions[0].to` reported by the parser. - /// @param parsedTxs The parsed transactions array (length == 1). - function verifyDeployBundle(IGnosisSafe safe, address parsedTo, SafeTx[] memory parsedTxs) internal view { - address v4Impl = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; - assertV4ImplDeployed(v4Impl); - - address factoryAddr = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; - assertCloneFactoryDeployed(factoryAddr); - if (parsedTo != factoryAddr) revert VerifyMismatch("to"); + vm.stopBroadcast(); - bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: address(safe)})); - SafeTx memory expected = SafeTx({ - to: factoryAddr, value: 0, data: abi.encodeCall(ICloneableFactoryV2.clone, (v4Impl, initData)), operation: 0 - }); + _assertPostState(clone, deployer, v4Impl); - if (parsedTxs[0].to != expected.to) revert VerifyMismatch("to"); - if (parsedTxs[0].value != expected.value) revert VerifyMismatch("value"); - if (keccak256(parsedTxs[0].data) != keccak256(expected.data)) revert VerifyMismatch("data"); - - bytes32 liveHash = LibSafeOps.computeSafeTxHashViaSafe(safe, expected, safe.nonce()); - bytes32 artifactHash = LibSafeOps.computeSafeTxHashViaSafe(safe, parsedTxs[0], safe.nonce()); - if (liveHash != artifactHash) revert VerifyMismatch("safeTxHash"); + // Log the clone address prominently so the operator can copy it + // into the post-execution pin PR + // (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` + + // `..._CODEHASH` hydration). + console2.log("==== V4 AUTHORISER CLONE DEPLOYED ===="); + console2.log("Clone:", vm.toString(clone)); + console2.log("CloneCodehash:", vm.toString(clone.codehash)); + console2.log("======================================"); } - /// @notice Grants-bundle verify branch. Reads the clone address - /// from `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` via - /// `_resolveClone()`, runs the same pre-flight as `mirrorGrants()` - /// (pin non-zero, pin has code, pin's codehash matches the lib, and - /// the auto-grants are held), asserts each parsed tx targets - /// the resolved clone with the canonical `grantRole(role, grantee)` - /// calldata for the matching non-admin slice of `expectedGrants()`, - /// and cross-checks the bundle's `MultiSend` SafeTxHash against the - /// live Safe's hash builder. - /// @param safe The live Safe handle. - /// @param parsedTo The `transactions[0].to` reported by the parser - /// — should equal the resolved clone address. - /// @param parsedTxs The parsed transactions array (length == 6). - function verifyGrantsBundle(IGnosisSafe safe, address parsedTo, SafeTx[] memory parsedTxs) internal view { - assertGrantsSliceInvariant(); - address clone = _resolveClone(); - if (clone == address(0)) revert V4AuthoriserCloneNotPinned(); - if (clone.code.length == 0) revert V4AuthoriserCloneNotDeployed(clone); - bytes32 expectedCloneCodehash = _resolveCloneCodehash(); + /// @notice Post-state assertion invoked after the deploy sequence. + /// Asserts the clone's EIP-1167 shape, every pinned expected grant + /// holds, and the deployer no longer holds any auto-granted admin + /// role. Split from `run()` so tests can call it against a clone + /// they configured under `vm.startPrank`. + /// @param clone The freshly-configured clone. + /// @param deployer The address that broadcast the sequence — must + /// hold no `_ADMIN` role post-renounce. + /// @param v4Impl The pinned V4 impl the clone proxies; the expected + /// codehash is re-derived from this address so the check does not + /// depend on the (still-placeholder) codehash pin. + function _assertPostState(address clone, address deployer, address v4Impl) internal view { + // EIP-1167 shape + embedded impl match what the pinned V4 impl + // produces. + bytes32 expectedCloneCodehash = computeMinimalProxyCodehash(v4Impl); bytes32 actualCloneCodehash = clone.codehash; if (actualCloneCodehash != expectedCloneCodehash) { - revert V4AuthoriserCloneCodehashMismatch(clone, expectedCloneCodehash, actualCloneCodehash); + revert CloneCodehashMismatch(clone, expectedCloneCodehash, actualCloneCodehash); } - // Mirror `mirrorGrants()`'s grant-state pre-flight: the clone must hold - // the seven auto-grants (i.e. it was initialised with the Safe as - // admin), otherwise every grantRole tx in the bundle would revert on - // execution. Gives a signer verifying the artifact the same guarantee - // as the authoring path. - assertAutoGrantsHeld(clone, address(safe)); - - if (parsedTo != clone) revert VerifyMismatch("to"); - + IAccessControl acl = IAccessControl(clone); RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 0; i < GRANTS_TX_COUNT; i++) { - RoleGrant memory grant = allGrants[MIRROR_START_INDEX + i]; - SafeTx memory expected = SafeTx({ - to: clone, - value: 0, - data: abi.encodeCall(IAccessControl.grantRole, (grant.role, grant.grantee)), - operation: 0 - }); - if (parsedTxs[i].to != expected.to) revert VerifyMismatch("to"); - if (parsedTxs[i].value != expected.value) revert VerifyMismatch("value"); - if (keccak256(parsedTxs[i].data) != keccak256(expected.data)) revert VerifyMismatch("data"); + + // Every `(role, grantee)` in `expectedGrants()` holds. Covers the + // five V3-era admin grants (swapped onto the Safe in step 3) AND + // the six operational grants from step 2 in one sweep. + for (uint256 i = 0; i < allGrants.length; i++) { + if (!acl.hasRole(allGrants[i].role, allGrants[i].grantee)) { + revert ExpectedGrantMissing(allGrants[i].role, allGrants[i].grantee); + } } - bytes32 liveHash = LibSafeOps.computeMultiSendSafeTxHash(safe, _buildExpectedGrantsTxs(clone), safe.nonce()); - bytes32 artifactHash = LibSafeOps.computeMultiSendSafeTxHash(safe, parsedTxs, safe.nonce()); - if (liveHash != artifactHash) revert VerifyMismatch("safeTxHash"); - } + bytes32[AUTO_GRANTED_ADMIN_COUNT] memory adminRoles = autoGrantedAdminRoles(); - /// @notice Assert `LibAuthoriserInvariants.expectedGrants()` still has - /// exactly the length the hand-maintained non-admin slice assumes - /// (`MIRROR_START_INDEX + GRANTS_TX_COUNT`), so a lib reshape that would - /// silently truncate or mis-select the mirrored grants fails fast with a - /// typed error rather than as an out-of-bounds panic. - function assertGrantsSliceInvariant() internal pure { - uint256 actual = LibAuthoriserInvariants.expectedGrants().length; - if (actual != MIRROR_START_INDEX + GRANTS_TX_COUNT) { - revert GrantsSliceLengthDrift(actual, MIRROR_START_INDEX + GRANTS_TX_COUNT); + // The Safe holds every auto-granted `_ADMIN` role — including the + // two corporate-action admins that `expectedGrants()` doesn't + // carry (V4-only roles the override's `initialize` adds). + address safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + for (uint256 i = 0; i < adminRoles.length; i++) { + if (!acl.hasRole(adminRoles[i], safe)) { + revert ExpectedGrantMissing(adminRoles[i], safe); + } } - } - /// @notice Rebuild the canonical six-tx grants array for `clone`. - /// Factored out so the `verify` SafeTxHash cross-check can compare - /// the live-pre-flight bundle to the artifact bundle without - /// duplicating the loop body in two places. - /// @param clone The clone address each grant targets. - /// @return txs The canonical six-tx grants array. - function _buildExpectedGrantsTxs(address clone) internal pure returns (SafeTx[] memory txs) { - RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); - txs = new SafeTx[](GRANTS_TX_COUNT); - for (uint256 i = 0; i < GRANTS_TX_COUNT; i++) { - RoleGrant memory grant = allGrants[MIRROR_START_INDEX + i]; - txs[i] = SafeTx({ - to: clone, - value: 0, - data: abi.encodeCall(IAccessControl.grantRole, (grant.role, grant.grantee)), - operation: 0 - }); + // The deployer holds none of the auto-granted `_ADMIN` roles. + // If any survived step 4, the deployer key still has root + // privileges over that role's grant map — closes the + // "transitional trust window" for those specific roles. + for (uint256 i = 0; i < adminRoles.length; i++) { + if (acl.hasRole(adminRoles[i], deployer)) { + revert DeployerStillHoldsAdminRole(adminRoles[i], deployer); + } } } - /// @notice Assert the V4 impl is deployed at the pinned address - /// with the pinned codehash. Pulled out so `run()` and the deploy- - /// branch of `verify()` share the same pre-flight. + /// @notice The seven `_ADMIN` roles the base + ST0x-override + /// `initialize` grant to the supplied `initialAdmin` config. + /// Hand-listed (in source-order of the `_grantRole` calls in the + /// impl) rather than derived from `expectedGrants()` because the + /// auto-grants overlap with — but are not identical to — the lib + /// map's indices 0..4: the V3-era map is missing the two + /// corporate-action admins the V4 override adds. + /// @return roles The seven role hashes, in `_grantRole` order. + function autoGrantedAdminRoles() internal pure returns (bytes32[AUTO_GRANTED_ADMIN_COUNT] memory roles) { + roles[0] = keccak256("CERTIFY_ADMIN"); + roles[1] = keccak256("CONFISCATE_RECEIPT_ADMIN"); + roles[2] = keccak256("CONFISCATE_SHARES_ADMIN"); + roles[3] = keccak256("DEPOSIT_ADMIN"); + roles[4] = keccak256("WITHDRAW_ADMIN"); + roles[5] = keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"); + roles[6] = keccak256("CANCEL_CORPORATE_ACTION_ADMIN"); + } + + /// @notice Assert the V4 impl is deployed at the pinned address with + /// the pinned codehash. /// @param impl The V4 impl address to check. function assertV4ImplDeployed(address impl) internal view { if (impl.code.length == 0) revert V4ImplNotDeployed(impl); @@ -621,129 +323,27 @@ contract DeployV4AuthoriserClone is Script { if (actual != expected) revert CloneFactoryCodehashMismatch(factory, expected, actual); } - /// @notice Assert the simulated clone's runtime codehash matches - /// the EIP-1167 minimal-proxy runtime computed from the V4 impl - /// literal. Used by `run()` against the freshly-simulated clone, - /// which has no representation in the lib pin yet (the pin is still - /// `bytes32(0)` until the post-execution hydrate PR lands), so the - /// expected hash must be re-derived from the V4 impl literal here. - /// @param clone The clone address to check. - /// @param expected The pre-computed minimal-proxy codehash. - function assertCloneCodehash(address clone, bytes32 expected) internal view { - bytes32 actual = clone.codehash; - if (actual != expected) revert CloneCodehashMismatch(clone, expected, actual); - } - - /// @notice Assert the seven role grants the base - /// `OffchainAssetReceiptVaultAuthorizerV1` and the ST0x override - /// `initialize` collectively grant to the supplied admin (the - /// Safe) all hold on the supplied clone. Iterated in the same - /// order as the source `_grantRole` calls in the impl so a missing - /// role surfaces the first failure deterministically. - /// @param clone The clone to check. - /// @param expectedAdmin The address that should hold every auto- - /// granted `_ADMIN` role (in production, the ST0x token-owner - /// Safe). - function assertAutoGrantsHeld(address clone, address expectedAdmin) internal view { - bytes32[7] memory autoRoles = autoGrantedAdminRoles(); - IAccessControl acl = IAccessControl(clone); - for (uint256 i = 0; i < autoRoles.length; i++) { - if (!acl.hasRole(autoRoles[i], expectedAdmin)) { - revert AutoGrantMissing(clone, autoRoles[i], expectedAdmin); - } - } - } - - /// @notice Assert the six non-admin grants this script is about - /// to mirror in are NOT yet held on a fresh clone. Together with - /// `assertAutoGrantsHeld` this proves the `run()`-bundle leaves - /// exactly the seven auto-grants and nothing else, so the mirror - /// bundle is genuinely adding new state. - /// @param clone The clone to check. - function assertNonAdminGrantsAbsent(address clone) internal view { + /// @notice Assert the invariant map's slice constants + /// (`MIRROR_START_INDEX`, `MIRROR_COUNT`) still line up with + /// `LibAuthoriserInvariants.expectedGrants()`. Trips + /// `GrantsSliceOutOfRange` if the invariant map has grown or shrunk + /// away from what this script expects. + function assertGrantsSliceInvariant() internal pure { + uint256 expectedLength = MIRROR_START_INDEX + MIRROR_COUNT; RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); - IAccessControl acl = IAccessControl(clone); - for (uint256 i = 0; i < GRANTS_TX_COUNT; i++) { - RoleGrant memory grant = allGrants[MIRROR_START_INDEX + i]; - if (acl.hasRole(grant.role, grant.grantee)) { - revert UnexpectedAutoGrantHeld(clone, grant.role, grant.grantee); - } + if (allGrants.length != expectedLength) { + revert GrantsSliceOutOfRange(MIRROR_START_INDEX, MIRROR_COUNT, allGrants.length); } } - /// @notice The seven `_ADMIN` roles the base + override - /// `initialize` grants to the supplied `initialAdmin` config. - /// Hand-listed (in source-order of the `_grantRole` calls in the - /// impl) rather than re-derived from `expectedGrants()` because the - /// auto-grants overlap with — but are not identical to — - /// `expectedGrants()` indices 0..4 (the V3 set is missing the two - /// corporate-action admins the override adds). - /// @return roles The seven role hashes, in `_grantRole` order. - function autoGrantedAdminRoles() internal pure returns (bytes32[7] memory roles) { - // Order matches the `_grantRole` sequence in the base impl - // (`CERTIFY_ADMIN`, `CONFISCATE_RECEIPT_ADMIN`, - // `CONFISCATE_SHARES_ADMIN`, `DEPOSIT_ADMIN`, `WITHDRAW_ADMIN`) - // followed by the override's two extra grants - // (`SCHEDULE_CORPORATE_ACTION_ADMIN`, `CANCEL_CORPORATE_ACTION_ADMIN`). - roles[0] = keccak256("CERTIFY_ADMIN"); - roles[1] = keccak256("CONFISCATE_RECEIPT_ADMIN"); - roles[2] = keccak256("CONFISCATE_SHARES_ADMIN"); - roles[3] = keccak256("DEPOSIT_ADMIN"); - roles[4] = keccak256("WITHDRAW_ADMIN"); - roles[5] = keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"); - roles[6] = keccak256("CANCEL_CORPORATE_ACTION_ADMIN"); - } - /// @notice Compute the EIP-1167 minimal-proxy runtime codehash for /// the supplied implementation. The OpenZeppelin `Clones` impl /// deploys this exact bytecode shape: - /// `` - /// which is what `CloneFactory.clone` produces under the hood. - /// @dev Computes the codehash in-source from the canonical EIP-1167 byte - /// constants (`rain-extrospection`'s `ERC1167_PREFIX` / `ERC1167_SUFFIX`) - /// rather than hardcoding the proxy bytes, so it stays independent of the - /// post-deploy `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH` pin - /// (which is `bytes32(0)` until the clone is deployed and the post-execution - /// PR hydrates it). + /// ``. /// @param impl The implementation address embedded in the minimal /// proxy. /// @return The keccak256 of the minimal-proxy runtime bytecode. function computeMinimalProxyCodehash(address impl) internal pure returns (bytes32) { return keccak256(abi.encodePacked(ERC1167_PREFIX, impl, ERC1167_SUFFIX)); } - - /// @notice Fish the `NewClone(sender, implementation, clone)` - /// event out of a recorded log array. The factory emits this - /// exactly once per `clone` call; we match by emitter address (the - /// factory) and event signature, then sanity-check the - /// implementation field equals `expectedImpl`. - /// @dev Reverts with a descriptive `require` message if the event - /// is absent — that's an invariant break on the factory rather - /// than user input, so a string-reason is a reasonable choice - /// here (it's never reached in a healthy run). - /// @param logs The recorded log array from `vm.getRecordedLogs()`. - /// @param factory The CloneFactory address that should have emitted - /// the event. - /// @param expectedImpl The implementation address embedded in the - /// event's `implementation` argument; cross-checked against the - /// pinned V4 impl. - /// @return clone The clone address from the event's `clone` field. - function extractCloneAddressFromLogs(Vm.Log[] memory logs, address factory, address expectedImpl) - internal - pure - returns (address clone) - { - bytes32 sig = keccak256("NewClone(address,address,address)"); - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].emitter != factory) continue; - if (logs[i].topics.length == 0) continue; - if (logs[i].topics[0] != sig) continue; - // `NewClone` has no indexed args; sender, implementation, - // and clone are all in `data` as three packed addresses. - (, address implFromEvent, address cloneFromEvent) = abi.decode(logs[i].data, (address, address, address)); - require(implFromEvent == expectedImpl, "DeployV4AuthoriserClone: NewClone impl mismatch"); - return cloneFromEvent; - } - revert("DeployV4AuthoriserClone: NewClone not emitted"); - } } diff --git a/src/lib/LibProdDeployV4.sol b/src/lib/LibProdDeployV4.sol index e4584c20..b6d95f79 100644 --- a/src/lib/LibProdDeployV4.sol +++ b/src/lib/LibProdDeployV4.sol @@ -440,13 +440,16 @@ library LibProdDeployV4 { /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. /// /// **PLACEHOLDER** (`address(0)` literal) until the clone is deployed - /// against the V4 impl as a one-off ops step (initialised with the - /// ST0x token-owner Safe as `initialAdmin`, then the non-admin grants - /// from `LibAuthoriserInvariants.expectedGrants()` are mirrored onto - /// it). The clone's address is not deterministic ahead of time (Rain - /// `CloneFactory` uses non-deterministic `Clones.clone`); the - /// post-deploy edit hand-writes the real literal in place of - /// `address(0)` here. + /// against the V4 impl as a one-off ops step. The broadcast script + /// `20260619-deploy-v4-authoriser-clone.s.sol` initialises the clone + /// with the deploy key as `initialAdmin` (so the auto-granted `_ADMIN` + /// roles land on the deploy key), mirrors the non-admin grants from + /// `LibAuthoriserInvariants.expectedGrants()`, grants every auto-granted + /// `_ADMIN` role to the ST0x token-owner Safe, then renounces them from + /// the deploy key — leaving the Safe as sole admin. The clone's address + /// is not deterministic ahead of time (Rain `CloneFactory` uses + /// non-deterministic `Clones.clone`); the post-deploy edit hand-writes + /// the real literal in place of `address(0)` here. /// /// Lives in this lib (the deploy artifacts pin) rather than in /// `LibAuthoriserInvariants` because it's a deploy target, not a @@ -458,13 +461,19 @@ library LibProdDeployV4 { /// @notice The pinned EIP-1167 runtime codehash for /// `STOX_PROD_AUTHORISER_V4_CLONE`. Deterministic from the V4 impl /// address embedded in the minimal-proxy runtime - /// (`363d3d373d3d3d363d735af43d82803e903d91602b57fd5bf3`); the - /// invariant uses it to prove the clone hasn't been etched over. + /// (`ERC1167_PREFIX ERC1167_SUFFIX`) — so unlike the clone ADDRESS + /// above (non-deterministic, awaits the broadcast), this is knowable as + /// soon as the impl address is pinned and is hydrated ahead of the deploy. + /// The invariant uses it to prove whatever lands at the pinned clone + /// address is exactly the EIP-1167 proxy of the audited V4 impl and hasn't + /// been etched over. /// - /// **PLACEHOLDER** — fill in once the V4 impl address is known and the - /// clone is deployed. Easiest path: compute via - /// `keccak256(abi.encodePacked(hex"363d3d373d3d3d363d73", v4Impl, hex"5af43d82803e903d91602b57fd5bf3"))`. - bytes32 constant STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH = bytes32(0); + /// Computed as `keccak256(abi.encodePacked(ERC1167_PREFIX, + /// STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, ERC1167_SUFFIX))` + /// — the template constants from `rain-extrospection`'s + /// `LibExtrospectERC1167Proxy`; cross-checked by `LibProdDeployV4Test`. + bytes32 constant STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH = + 0x2089950d3cc1112dd66a58adcfadeadc490b50053ac67be8bc676b4a2dcd1717; // ========================================================================= // Per-release creation + runtime bytecode (frozen historicals). diff --git a/test/script/20260619-deploy-v4-authoriser-clone.t.sol b/test/script/20260619-deploy-v4-authoriser-clone.t.sol index 1e558c00..a4827da7 100644 --- a/test/script/20260619-deploy-v4-authoriser-clone.t.sol +++ b/test/script/20260619-deploy-v4-authoriser-clone.t.sol @@ -5,6 +5,14 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {VmSafe} from "forge-std-1.16.1/src/Vm.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 {LibCloneFactoryDeploy} from "rain-factory-0.1.1/src/lib/LibCloneFactoryDeploy.sol"; +import {ERC1167_PREFIX, ERC1167_SUFFIX} from "rain-extrospection-0.1.1/src/lib/LibExtrospectERC1167Proxy.sol"; + +import {ICloneableFactoryV2} from "rain-factory-0.1.1/src/interface/ICloneableFactoryV2.sol"; +import { + OffchainAssetReceiptVaultAuthorizerV1Config +} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; import { DeployV4AuthoriserClone, @@ -12,597 +20,330 @@ import { V4ImplCodehashMismatch, CloneFactoryNotDeployed, CloneFactoryCodehashMismatch, - CloneCodehashMismatch, - V4AuthoriserCloneNotPinned, - V4AuthoriserCloneNotDeployed, - V4AuthoriserCloneCodehashMismatch, - AutoGrantMissing, - UnexpectedAutoGrantHeld, - VerifyMismatch, - VerifyUnknownBundleShape + DeployerStillHoldsAdminRole, + ExpectedGrantMissing, + CloneCodehashMismatch } from "../../script/20260619-deploy-v4-authoriser-clone.s.sol"; -import {TestableDeployV4AuthoriserClone} from "./TestableDeployV4AuthoriserClone.sol"; -import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibSafeInvariants, SafeOwnerCountMismatch} from "../../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; -import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; import { StoxOffchainAssetReceiptVaultAuthorizerV1 } from "../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; -import {LibCloneFactoryDeploy} from "rain-factory-0.1.1/src/lib/LibCloneFactoryDeploy.sol"; -import {ICloneableFactoryV2} from "rain-factory-0.1.1/src/interface/ICloneableFactoryV2.sol"; -import { - OffchainAssetReceiptVaultAuthorizerV1Config -} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; +import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; +import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {DeployV4AuthoriserCloneHarness} from "./DeployV4AuthoriserCloneHarness.sol"; /// @title DeployV4AuthoriserCloneTest -/// @notice End-to-end fork tests for the V4 authoriser clone deploy + grants -/// mirror script. Selects an unpinned Base head fork (same precedent as -/// `MigrateMultisigThresholdTest`), etches the V4 impl bytecode at the -/// `LibProdDeployV4`-pinned address (the impl has not yet been Zoltu-deployed -/// at the time this script lands), then exercises `run()`, `mirrorGrants()`, -/// the `verify()` round-trip for both bundles, and the inverted preconditions. -/// @dev The V4 impl etch step is the only test-side scaffolding required to -/// pass the deploy-bundle pre-flight; the canonical Rain `CloneFactory` is -/// already deployed on Base at the `LibCloneFactoryDeploy` pinned address, -/// and the ST0x token-owner Safe passes `LibSafeInvariants.assertAll` against -/// the live chain. +/// @notice Fork tests for the broadcast-driven V4 authoriser clone deploy. +/// +/// Test setup runs each test against an unpinned Base head fork. The script +/// is invoked via `vm.prank(deployer, deployer)`, so `msg.sender` in `run()` +/// is the synthetic deployer address for the whole call. `vm.startBroadcast()` +/// inside the script no-ops under `forge test` — state changes still apply +/// against the fork snapshot, they just are not re-broadcast. /// -/// The grants-bundle suite uses `TestableDeployV4AuthoriserClone` (an -/// `override`-of-`_resolveClone` subclass) to simulate the post-hydrate state -/// of `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` without monkeying with -/// library bytecode. Tests that exercise the unhydrated pre-flight failure -/// instantiate the base script directly so `_resolveClone()` returns -/// `address(0)` (the lib constant's compile-time value on this branch). +/// @dev The V4 impl has not yet been Zoltu-deployed on Base at the time this +/// script lands. Each test etches the V4 impl runtime bytecode at the pinned +/// address so the impl pre-flight passes; the runtime is captured from a +/// freshly-compiled `StoxOffchainAssetReceiptVaultAuthorizerV1`, whose +/// codehash matches the `LibProdDeployV4` pin by construction. contract DeployV4AuthoriserCloneTest is Test { - /// @notice The script under test, deployed fresh per fork. The bare - /// (un-subclassed) script is used by tests that read the lib pin's true - /// compile-time value (`address(0)`); grants-bundle tests use - /// `TestableDeployV4AuthoriserClone` instead. DeployV4AuthoriserClone internal script; - - /// @notice Live Safe handle. - IGnosisSafe internal safe; - - /// @notice The pinned V4 impl runtime bytecode (captured from a - /// freshly-deployed instance and etched at the pin address). + DeployV4AuthoriserCloneHarness internal harness; + address internal deployer; + address internal safe; + address internal v4Impl; bytes internal v4ImplRuntime; + address internal cloneFactory; - /// @notice Selects the Base fork at chain head, deploys the script, - /// captures the live Safe, and etches the V4 impl runtime bytecode at - /// the `LibProdDeployV4` pin so the script's V4-impl pre-flight passes. - /// @dev The impl has not yet been Zoltu-deployed on Base; the etch is - /// the test's stand-in for the eventual on-chain deploy. The runtime - /// code is sourced from a freshly-compiled - /// `StoxOffchainAssetReceiptVaultAuthorizerV1`, so its codehash matches - /// the `LibProdDeployV4` pin by construction (the pin was generated from - /// the same compiled bytecode). function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); script = new DeployV4AuthoriserClone(); - safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + harness = new DeployV4AuthoriserCloneHarness(); + deployer = makeAddr("deployer"); + vm.deal(deployer, 100 ether); + safe = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + v4Impl = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; + cloneFactory = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; + StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); v4ImplRuntime = address(impl).code; - vm.etch(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, v4ImplRuntime); + vm.etch(v4Impl, v4ImplRuntime); } - /// @notice `run()` dry-run completes against the live pre-state, writes - /// the deploy bundle artifact, the artifact has the expected single-tx - /// shape, and the script's logs include a predicted clone address. - /// @dev The clone deploy is also asserted to have left the seven auto- - /// granted `_ADMIN` roles in place on the live fork — `run()` calls into - /// the CloneFactory under `vm.prank(safe)`, which actually deploys a - /// real clone on the active fork state, so post-run state is observable. - function testRunCompletesAndWritesDeployArtifact() external { + /// @notice Happy path: replicate the deploy sequence with + /// `vm.prank(deployer)` per external call, then run the same + /// `_assertPostState` check `run()` would through the harness. + /// Mirrors `MigrateBeaconOwnersTest.simulateTransfers` — the state + /// change is driven inline because `vm.startBroadcast` (which the + /// script wraps around the sequence) is mutually exclusive with + /// `vm.prank` in `forge test`. + function testHappyPathLeavesExpectedGrantsAndNoDeployerAdmin() external { selectBaseFork(); - script.run(); - string memory artifactPath = string.concat(vm.projectRoot(), "/out/v4-authoriser-clone-deploy.json"); - string memory json = vm.readFile(artifactPath); + // Step 1: deploy the clone under the deployer. + bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: deployer})); + vm.prank(deployer, deployer); + address clone = ICloneableFactoryV2(cloneFactory).clone(v4Impl, initData); - string memory bundleName = vm.parseJsonString(json, ".meta.name"); - assertEq(bundleName, "ST0x V4 authoriser - deploy clone", "meta.name pinned"); + IAccessControl acl = IAccessControl(clone); + RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); + bytes32[7] memory adminRoles = _autoGrantedAdminRoles(); + + // Step 2: mirror the six non-admin operational grants under + // the deployer (who holds every `_ADMIN` role from init). + for (uint256 i = 5; i < allGrants.length; i++) { + vm.prank(deployer, deployer); + acl.grantRole(allGrants[i].role, allGrants[i].grantee); + } - bool hasFirstTx = vm.keyExistsJson(json, ".transactions[0].to"); - bool hasSecondTx = vm.keyExistsJson(json, ".transactions[1].to"); - assertTrue(hasFirstTx, "first transaction present"); - assertFalse(hasSecondTx, "exactly one transaction emitted"); + // Step 3: grant each of the SEVEN auto-granted `_ADMIN` roles + // (five V3-era + two corporate-action admins) to the Safe. + for (uint256 i = 0; i < adminRoles.length; i++) { + vm.prank(deployer, deployer); + acl.grantRole(adminRoles[i], safe); + } - // The deploy bundle's only tx targets the canonical CloneFactory. - address parsedTo = vm.parseJsonAddress(json, ".transactions[0].to"); - assertEq(parsedTo, address(0x444acC29d63fa643E8adCC35FD9aa6DE111dCb39), "tx targets canonical CloneFactory"); + // Step 4: renounce each `_ADMIN` role from the deployer. + for (uint256 i = 0; i < adminRoles.length; i++) { + vm.prank(deployer, deployer); + acl.renounceRole(adminRoles[i], deployer); + } - // ...and carries the canonical `clone(v4Impl, abi.encode(Config(Safe)))` - // calldata, not just the right target. - bytes memory parsedData = vm.parseJsonBytes(json, ".transactions[0].data"); - assertEq(parsedData, _expectedDeployData(), "deploy tx calldata mismatch"); - } + // Post-state check via the harness — same code path the script's + // `run()` executes after `vm.stopBroadcast()`. + harness.callAssertPostState(clone, deployer, v4Impl); - /// @notice Happy-path `mirrorGrants()` against a fork-deployed clone - /// produced by `run()`. Uses `TestableDeployV4AuthoriserClone` so the - /// lib-pin-overridden `_resolveClone()` returns the same address the - /// deploy simulated. The mirror bundle emits exactly six `grantRole` - /// txs targeting the clone, and the post-state matches the full - /// `LibAuthoriserInvariants.expectedGrants()` map. - function testMirrorGrantsCompletesAndWritesGrantsArtifact() external { - selectBaseFork(); - // Swap the bare script for the testable subclass so - // `_resolveClone()` / `_resolveCloneCodehash()` return the post- - // hydrate values rather than the lib constants' compile-time - // `address(0)` / `bytes32(0)`. - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - testable.run(); - address clone = testable.lastPredictedClone(); - assertTrue(clone != address(0), "deploy run produced a clone"); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - - testable.mirrorGrants(); - - string memory artifactPath = string.concat(vm.projectRoot(), "/out/v4-authoriser-clone-grants.json"); - string memory json = vm.readFile(artifactPath); - string memory bundleName = vm.parseJsonString(json, ".meta.name"); - assertEq(bundleName, "ST0x V4 authoriser - mirror non-admin grants", "meta.name pinned"); - - // Six tx entries, no more, no less. - assertTrue(vm.keyExistsJson(json, ".transactions[5].to"), "sixth transaction present"); - assertFalse(vm.keyExistsJson(json, ".transactions[6].to"), "exactly six transactions emitted"); - - // Each tx targets the clone with the canonical grantRole(role, - // grantee) calldata for the matching non-admin slice (indices 5..10) - // of expectedGrants() — not just the right target. - RoleGrant[] memory expected = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 0; i < 6; i++) { - string memory toPath = string.concat(".transactions[", vm.toString(i), "].to"); - assertEq(vm.parseJsonAddress(json, toPath), clone, "tx targets clone"); - RoleGrant memory g = expected[5 + i]; - bytes memory parsedData = vm.parseJsonBytes(json, string.concat(".transactions[", vm.toString(i), "].data")); - assertEq( - parsedData, abi.encodeCall(IAccessControl.grantRole, (g.role, g.grantee)), "grant tx calldata mismatch" - ); + // Redundant fine-grained assertions so any regression surfaces + // here rather than as a plain "assertPostState reverted". + for (uint256 i = 0; i < allGrants.length; i++) { + assertTrue(acl.hasRole(allGrants[i].role, allGrants[i].grantee), "expected grant missing on live clone"); } - - // Post-state: the clone holds the full expectedGrants() map. - IAccessControl acl = IAccessControl(clone); - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 0; i < grants.length; i++) { - // V3 indices 0..4 are the five `_ADMIN` grants the base init - // auto-grants; indices 5..10 are the six mirror grants. All 11 - // should hold post-mirror. - assertTrue(acl.hasRole(grants[i].role, grants[i].grantee), "expected grant held post-mirror"); + for (uint256 i = 0; i < adminRoles.length; i++) { + assertTrue(acl.hasRole(adminRoles[i], safe), "Safe missing an auto-granted admin role"); + assertFalse(acl.hasRole(adminRoles[i], deployer), "deployer retained an admin role"); } } - /// @notice `verify()` accepts the deploy bundle artifact emitted by - /// `run()`. The deploy branch never consults `_resolveClone()`, so the - /// bare script suffices. - function testVerifyAcceptsRunDeployArtifact() external { - selectBaseFork(); - // `run()` simulates the inner clone deploy via `vm.prank(safe)`, - // which actually deploys a clone and increments the safe's - // implicit "nonce-for-CREATE" footprint. Snapshot first so - // verify() sees the pre-run state. - uint256 snap = vm.snapshotState(); - script.run(); - string memory artifactPath = string.concat(vm.projectRoot(), "/out/v4-authoriser-clone-deploy.json"); - vm.revertToState(snap); - // Restore the V4 impl etch after the revert (the etch lives in - // the fork's state slot and the revert may or may not preserve - // it depending on whether the snapshot captured it; etching - // again is idempotent). - vm.etch(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, v4ImplRuntime); - script.verify(artifactPath); + /// @notice The seven `_ADMIN` roles the base + ST0x-override + /// `initialize` auto-grant. Mirrors the script's + /// `autoGrantedAdminRoles()` (internal there, re-listed here). + function _autoGrantedAdminRoles() internal pure returns (bytes32[7] memory roles) { + roles[0] = keccak256("CERTIFY_ADMIN"); + roles[1] = keccak256("CONFISCATE_RECEIPT_ADMIN"); + roles[2] = keccak256("CONFISCATE_SHARES_ADMIN"); + roles[3] = keccak256("DEPOSIT_ADMIN"); + roles[4] = keccak256("WITHDRAW_ADMIN"); + roles[5] = keccak256("SCHEDULE_CORPORATE_ACTION_ADMIN"); + roles[6] = keccak256("CANCEL_CORPORATE_ACTION_ADMIN"); } - /// @notice `verify()` accepts the grants bundle artifact emitted by - /// `mirrorGrants()` against the fork-deployed clone. Uses the testable - /// subclass for both authoring + verification so `_resolveClone()` / - /// `_resolveCloneCodehash()` return the same simulated post-hydrate - /// values in both phases. - /// @dev Verifies against the real clone `run()` + `mirrorGrants()` leave on - /// the fork — its actual CloneFactory-deployed runtime and post-mirror - /// access-control state — rather than reverting and re-etching a - /// reconstructed proxy. That exercises the bytecode the factory really - /// deploys and satisfies the auto-grant pre-flight `verify()` now shares - /// with `mirrorGrants()`. - function testVerifyAcceptsMirrorGrantsArtifact() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - - testable.run(); - address clone = testable.lastPredictedClone(); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - testable.mirrorGrants(); - - string memory artifactPath = string.concat(vm.projectRoot(), "/out/v4-authoriser-clone-grants.json"); - testable.verify(artifactPath); - } - - /// @notice Inverted: pre-flight rejects a missing V4 impl with - /// `V4ImplNotDeployed`. `vm.etch` with empty bytes zeros the runtime - /// code at the pin so `impl.code.length == 0` trips first. + /// @notice Pre-flight rejects a missing V4 impl. `vm.etch` with empty + /// bytes zeros the runtime code at the pin so `impl.code.length == 0` + /// trips first. function testRunRejectsMissingV4Impl() external { selectBaseFork(); - address implAddr = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; - vm.etch(implAddr, new bytes(0)); - vm.expectRevert(abi.encodeWithSelector(V4ImplNotDeployed.selector, implAddr)); + vm.etch(v4Impl, ""); + vm.expectRevert(abi.encodeWithSelector(V4ImplNotDeployed.selector, v4Impl)); + vm.prank(deployer, deployer); script.run(); } - /// @notice Inverted: pre-flight rejects a V4 impl whose runtime - /// codehash drifts from the pin with `V4ImplCodehashMismatch`. Etches - /// a single-byte stub at the pin so the address has *some* code but - /// not the canonical bytecode. + /// @notice Pre-flight rejects a V4 impl whose codehash drifts from the + /// pinned value. Simulated by etching alien bytecode so `code.length > 0` + /// but the codehash mismatches. function testRunRejectsV4ImplCodehashDrift() external { selectBaseFork(); - address implAddr = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; - bytes memory stub = hex"60005260206000F3"; - vm.etch(implAddr, stub); - bytes32 expectedHash = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_CODEHASH_0_1_1; - bytes32 actualHash = keccak256(stub); - vm.expectRevert(abi.encodeWithSelector(V4ImplCodehashMismatch.selector, implAddr, expectedHash, actualHash)); - script.run(); - } - - /// @notice Inverted: pre-flight rejects a Safe whose owner count - /// drifts off the `LibSafeInvariants` pin with `SafeOwnerCountMismatch`. - /// Mocks `getOwners()` to return a single-entry array; the no-arg - /// `assertAll(safe)` expects six. - function testRunRejectsSafeOwnerCountDrift() external { - selectBaseFork(); - address[] memory drifted = new address[](1); - drifted[0] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_1; - vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getOwners.selector), abi.encode(drifted)); - - vm.expectRevert(abi.encodeWithSelector(SafeOwnerCountMismatch.selector, address(safe), uint256(6), uint256(1))); + bytes memory bogusCode = hex"60016000526001601ff3"; + vm.etch(v4Impl, bogusCode); + bytes32 expected = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_CODEHASH_0_1_1; + bytes32 actual = keccak256(bogusCode); + vm.expectRevert(abi.encodeWithSelector(V4ImplCodehashMismatch.selector, v4Impl, expected, actual)); + vm.prank(deployer, deployer); script.run(); } - /// @notice Inverted: `mirrorGrants()` against the bare script (whose - /// `_resolveClone()` returns the un-overridden lib constant — - /// `address(0)` until the post-execution hydrate PR merges) reverts - /// with `V4AuthoriserCloneNotPinned()` before any side-effecting - /// work. This is the forcing-function the rewrite exists to enforce: - /// the script refuses to author a grants bundle pointing at an - /// arbitrary operator-supplied address. - function testMirrorGrantsRejectsUnpinnedClone() external { - selectBaseFork(); - vm.expectRevert(abi.encodeWithSelector(V4AuthoriserCloneNotPinned.selector)); - script.mirrorGrants(); - } - - /// @notice Inverted: `mirrorGrants()` against a testable subclass whose - /// `_resolveClone()` returns an address that has code (so the - /// `V4AuthoriserCloneNotDeployed` check passes) but whose actual - /// codehash drifts from the simulated-post-hydrate codehash injected - /// via `setResolvedCloneCodehash()` reverts with - /// `V4AuthoriserCloneCodehashMismatch`. Etches a one-byte stub at a - /// fresh address so the actual codehash is deterministically the - /// keccak of that stub, then sets the expected codehash to a - /// distinct sentinel value so the inequality trips. - function testMirrorGrantsRejectsCodehashDriftedClone() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address driftedClone = address(0xdEADbeEF00000000000000000000000000000001); - bytes memory stub = hex"60005260206000F3"; - vm.etch(driftedClone, stub); - testable.setResolvedClone(driftedClone); - // Inject a deterministic, distinct codehash as the "expected" - // post-hydrate value. The actual codehash on the drifted clone is - // `keccak256(stub)`, which will not match this sentinel. - bytes32 expectedCodehash = keccak256("expected-codehash-sentinel"); - testable.setResolvedCloneCodehash(expectedCodehash); - - bytes32 actualCodehash = driftedClone.codehash; - assertTrue(actualCodehash != expectedCodehash, "test precondition: codehashes differ"); - vm.expectRevert( - abi.encodeWithSelector( - V4AuthoriserCloneCodehashMismatch.selector, driftedClone, expectedCodehash, actualCodehash - ) - ); - testable.mirrorGrants(); - } - - // ------------------------------------------------------------------------- - // CloneFactory pre-flight (deploy branch) - // ------------------------------------------------------------------------- - - /// @notice Inverted: `run()` rejects a missing canonical CloneFactory with - /// `CloneFactoryNotDeployed`. Zeros the runtime code at the pinned factory - /// address; the V4 impl etch still passes the prior pre-flight check. + /// @notice Pre-flight rejects a missing CloneFactory. `vm.etch` with + /// empty bytes at the factory pin address. function testRunRejectsMissingCloneFactory() external { selectBaseFork(); - address factory = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; - vm.etch(factory, new bytes(0)); - vm.expectRevert(abi.encodeWithSelector(CloneFactoryNotDeployed.selector, factory)); + vm.etch(cloneFactory, ""); + vm.expectRevert(abi.encodeWithSelector(CloneFactoryNotDeployed.selector, cloneFactory)); + vm.prank(deployer, deployer); script.run(); } - /// @notice Inverted: `run()` rejects a CloneFactory whose runtime codehash - /// drifts from the pin with `CloneFactoryCodehashMismatch`. Etches a stub - /// so the address has code but the wrong bytecode. + /// @notice Pre-flight rejects a CloneFactory whose codehash drifts from + /// the rain-factory pin. Simulated by etching alien bytecode so + /// `code.length > 0` but the codehash mismatches. function testRunRejectsCloneFactoryCodehashDrift() external { selectBaseFork(); - address factory = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; - bytes memory stub = hex"60005260206000F3"; - vm.etch(factory, stub); - bytes32 expectedHash = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_CODEHASH; - bytes32 actualHash = keccak256(stub); - vm.expectRevert( - abi.encodeWithSelector(CloneFactoryCodehashMismatch.selector, factory, expectedHash, actualHash) - ); + bytes memory bogusCode = hex"60016000526001601ff3"; + vm.etch(cloneFactory, bogusCode); + bytes32 expected = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_CODEHASH; + bytes32 actual = keccak256(bogusCode); + vm.expectRevert(abi.encodeWithSelector(CloneFactoryCodehashMismatch.selector, cloneFactory, expected, actual)); + vm.prank(deployer, deployer); script.run(); } - // ------------------------------------------------------------------------- - // Clone pin pre-flight (grants branch) - // ------------------------------------------------------------------------- + /// @notice Deploy + configure a clone under `deployer`, optionally + /// perturbing exactly one step so a specific `_assertPostState` + /// guard is the one that trips. With all skips disabled this + /// produces the same correct clone the happy path builds. + /// @param skipRenounce When true, step 4 is skipped so `deployer` + /// retains every auto-granted admin role. + /// @param skipMirrorIndex An `expectedGrants()` index (in + /// `[MIRROR_START..]`) whose operational grant is skipped, or + /// `type(uint256).max` to mirror all six. + /// @param skipAdminIndex An `autoGrantedAdminRoles()` index whose + /// grant-to-Safe is skipped, or `type(uint256).max` to grant all + /// seven. + /// @return clone The freshly-configured (possibly perturbed) clone. + function _deployAndConfigure(bool skipRenounce, uint256 skipMirrorIndex, uint256 skipAdminIndex) + internal + returns (address clone) + { + bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: deployer})); + vm.prank(deployer, deployer); + clone = ICloneableFactoryV2(cloneFactory).clone(v4Impl, initData); - /// @notice Inverted: `mirrorGrants()` rejects a pinned clone address that - /// is non-zero (passes the not-pinned check) but has no runtime code with - /// `V4AuthoriserCloneNotDeployed` — the hydrate PR landed before the deploy - /// bundle executed on Base. - function testMirrorGrantsRejectsCloneWithNoCode() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address uncoded = address(0xc10e0000000000000000000000000000000000A1); - testable.setResolvedClone(uncoded); - assertEq(uncoded.code.length, 0, "test precondition: resolved clone has no code"); - vm.expectRevert(abi.encodeWithSelector(V4AuthoriserCloneNotDeployed.selector, uncoded)); - testable.mirrorGrants(); - } + IAccessControl acl = IAccessControl(clone); + RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); + bytes32[7] memory adminRoles = _autoGrantedAdminRoles(); + + // Step 2: mirror the operational grants (indices 5..). + for (uint256 i = 5; i < allGrants.length; i++) { + if (i == skipMirrorIndex) continue; + vm.prank(deployer, deployer); + acl.grantRole(allGrants[i].role, allGrants[i].grantee); + } - // ------------------------------------------------------------------------- - // verify() rejection paths (the anti-tamper guarantee) - // ------------------------------------------------------------------------- + // Step 3: grant each auto-granted admin role to the Safe. + for (uint256 i = 0; i < adminRoles.length; i++) { + if (i == skipAdminIndex) continue; + vm.prank(deployer, deployer); + acl.grantRole(adminRoles[i], safe); + } - /// @notice `verify()` rejects an artifact whose chainId is not the live - /// chain with `VerifyMismatch("chainId")` — the first check, before any - /// bundle-shape branching. - function testVerifyRejectsWrongChainId() external { - selectBaseFork(); - string memory path = _writeArtifact(block.chainid + 1, _deployTxs(), "chainid"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "chainId")); - script.verify(path); + // Step 4: renounce each auto-granted admin role from the deployer. + if (!skipRenounce) { + for (uint256 i = 0; i < adminRoles.length; i++) { + vm.prank(deployer, deployer); + acl.renounceRole(adminRoles[i], deployer); + } + } } - /// @notice `verify()` rejects an artifact whose tx count is neither the - /// deploy bundle's 1 nor the grants bundle's 6 with - /// `VerifyUnknownBundleShape`. - function testVerifyRejectsUnknownBundleShape() external { + /// @notice `_assertPostState` reverts `DeployerStillHoldsAdminRole` + /// when step 4's renounce is skipped and the deployer keeps its + /// auto-granted admin roles. Proves the de-privilege guard fires. + function testAssertPostStateRejectsDeployerRetainingAdmin() external { selectBaseFork(); - SafeTx memory deployTx = _deployTxs()[0]; - SafeTx[] memory txs = new SafeTx[](2); - txs[0] = deployTx; - txs[1] = deployTx; - string memory path = _writeArtifact(block.chainid, txs, "shape"); - vm.expectRevert(abi.encodeWithSelector(VerifyUnknownBundleShape.selector, uint256(2))); - script.verify(path); + address clone = _deployAndConfigure(true, type(uint256).max, type(uint256).max); + bytes32 certifyAdmin = _autoGrantedAdminRoles()[0]; + vm.expectRevert(abi.encodeWithSelector(DeployerStillHoldsAdminRole.selector, certifyAdmin, deployer)); + harness.callAssertPostState(clone, deployer, v4Impl); } - /// @notice Deploy-branch `verify()` rejects a bundle whose tx target is not - /// the canonical CloneFactory with `VerifyMismatch("to")`. - function testVerifyRejectsTamperedDeployTarget() external { + /// @notice `_assertPostState` reverts `ExpectedGrantMissing` when an + /// operational grant from `expectedGrants()` is absent. Proves the + /// expected-grants sweep fires. + function testAssertPostStateRejectsMissingOperationalGrant() external { selectBaseFork(); - SafeTx[] memory txs = _deployTxs(); - txs[0].to = address(0x1111111111111111111111111111111111111111); - string memory path = _writeArtifact(block.chainid, txs, "deploy-to"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "to")); - script.verify(path); + RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); + uint256 skipped = 5; + address clone = _deployAndConfigure(false, skipped, type(uint256).max); + vm.expectRevert( + abi.encodeWithSelector(ExpectedGrantMissing.selector, allGrants[skipped].role, allGrants[skipped].grantee) + ); + harness.callAssertPostState(clone, deployer, v4Impl); } - /// @notice Deploy-branch `verify()` rejects a bundle carrying a non-zero - /// ETH value with `VerifyMismatch("value")`. - function testVerifyRejectsTamperedDeployValue() external { + /// @notice `_assertPostState` reverts `ExpectedGrantMissing` when the + /// Safe is missing an auto-granted admin role. Skips a corporate- + /// action admin specifically — those two are NOT in `expectedGrants()`, + /// so only the dedicated "Safe holds every admin role" sweep can catch + /// this. Proves that sweep fires. + function testAssertPostStateRejectsSafeMissingAdminRole() external { selectBaseFork(); - SafeTx[] memory txs = _deployTxs(); - txs[0].value = 1; - string memory path = _writeArtifact(block.chainid, txs, "deploy-value"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "value")); - script.verify(path); + bytes32[7] memory adminRoles = _autoGrantedAdminRoles(); + // Index 5 = SCHEDULE_CORPORATE_ACTION_ADMIN (V4-override only). + uint256 skippedAdmin = 5; + address clone = _deployAndConfigure(false, type(uint256).max, skippedAdmin); + vm.expectRevert(abi.encodeWithSelector(ExpectedGrantMissing.selector, adminRoles[skippedAdmin], safe)); + harness.callAssertPostState(clone, deployer, v4Impl); } - /// @notice Deploy-branch `verify()` rejects a bundle whose calldata is not - /// the canonical `clone(...)` call with `VerifyMismatch("data")`. - function testVerifyRejectsTamperedDeployData() external { + /// @notice `_assertPostState` reverts `CloneCodehashMismatch` when the + /// clone is an EIP-1167 proxy of an impl OTHER than the pinned V4 impl. + /// Proves the codehash guard fires — the check that whatever lands at + /// the clone address is exactly the audited V4 impl's proxy. + function testAssertPostStateRejectsNonMatchingCloneCodehash() external { selectBaseFork(); - SafeTx[] memory txs = _deployTxs(); - txs[0].data = hex"deadbeef"; - string memory path = _writeArtifact(block.chainid, txs, "deploy-data"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "data")); - script.verify(path); - } + // A second address carrying the same runtime but at a different + // location; a clone of it embeds that address, so its EIP-1167 + // codehash differs from the one derived from the pinned V4 impl. + address wrongImpl = makeAddr("wrongImpl"); + vm.etch(wrongImpl, v4ImplRuntime); + bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: deployer})); + vm.prank(deployer, deployer); + address badClone = ICloneableFactoryV2(cloneFactory).clone(wrongImpl, initData); - /// @notice Grants-branch `verify()` rejects a bundle whose first tx target - /// is not the resolved clone with `VerifyMismatch("to")`. - function testVerifyRejectsTamperedGrantTarget() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - testable.run(); - address clone = testable.lastPredictedClone(); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - - SafeTx[] memory txs = _grantsTxs(clone); - txs[0].to = address(0x2222222222222222222222222222222222222222); - string memory path = _writeArtifact(block.chainid, txs, "grant-to"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "to")); - testable.verify(path); - } + bytes32 expected = keccak256(abi.encodePacked(ERC1167_PREFIX, v4Impl, ERC1167_SUFFIX)); + bytes32 actual = badClone.codehash; + assertTrue(actual != expected, "test setup: wrong-impl clone codehash unexpectedly matched"); - /// @notice Grants-branch `verify()` rejects a bundle whose grantRole - /// calldata is tampered with `VerifyMismatch("data")`. - function testVerifyRejectsTamperedGrantData() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - testable.run(); - address clone = testable.lastPredictedClone(); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - - SafeTx[] memory txs = _grantsTxs(clone); - txs[2].data = hex"deadbeef"; - string memory path = _writeArtifact(block.chainid, txs, "grant-data"); - vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "data")); - testable.verify(path); + vm.expectRevert(abi.encodeWithSelector(CloneCodehashMismatch.selector, badClone, expected, actual)); + harness.callAssertPostState(badClone, deployer, v4Impl); } - // ------------------------------------------------------------------------- - // Internal grant-state assertions (reached directly via exposed wrappers — - // the production call sites in run() always see a real, correctly-shaped, - // freshly-deployed clone, so these revert branches are otherwise dead). - // ------------------------------------------------------------------------- - - /// @notice Inverted: `assertCloneCodehash` reverts `CloneCodehashMismatch` - /// when the clone's runtime codehash differs from the expected EIP-1167 - /// codehash. - function testAssertCloneCodehashRejectsMismatch() external { + /// @notice The impl's `initialize` auto-grants EXACTLY the seven + /// `_ADMIN` roles the script's `autoGrantedAdminRoles()` hand-list + /// enumerates to `initialAdmin`, and — critically — does NOT grant + /// `DEFAULT_ADMIN_ROLE`. If the impl granted an admin role outside the + /// hand-list (or the OZ root), the script's step-3 transfer + step-4 + /// renounce would silently miss it and the deployer would keep + /// privilege the post-state check never inspects. Pins the hand-list + /// to the real impl rather than to itself. + function testInitAutoGrantsExactlyTheSevenAdminRolesToInitialAdmin() external { selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address probe = address(0xC0Dec0dec0DeC0Dec0dEc0DEC0DEC0DEC0DEC0dE); - bytes memory stub = hex"60005260206000F3"; - vm.etch(probe, stub); - bytes32 actual = keccak256(stub); - bytes32 wrongExpected = keccak256("not-a-minimal-proxy"); - vm.expectRevert(abi.encodeWithSelector(CloneCodehashMismatch.selector, probe, wrongExpected, actual)); - testable.exposed_assertCloneCodehash(probe, wrongExpected); - } + address initialAdmin = makeAddr("someInitialAdmin"); + bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: initialAdmin})); + vm.prank(deployer, deployer); + address clone = ICloneableFactoryV2(cloneFactory).clone(v4Impl, initData); - /// @notice Inverted: `assertAutoGrantsHeld` reverts `AutoGrantMissing` when - /// one of the seven auto-granted `_ADMIN` roles is not held by the admin. - /// Mocks every `hasRole` true except `CONFISCATE_RECEIPT_ADMIN`, so the - /// iteration trips on the missing role. - function testAssertAutoGrantsRejectsMissing() external { - selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address clone = address(0xAcc0000000000000000000000000000000000001); - address admin = address(safe); - vm.mockCall(clone, abi.encodeWithSelector(IAccessControl.hasRole.selector), abi.encode(true)); - bytes32 missingRole = keccak256("CONFISCATE_RECEIPT_ADMIN"); - vm.mockCall( - clone, abi.encodeWithSelector(IAccessControl.hasRole.selector, missingRole, admin), abi.encode(false) - ); - vm.expectRevert(abi.encodeWithSelector(AutoGrantMissing.selector, clone, missingRole, admin)); - testable.exposed_assertAutoGrantsHeld(clone, admin); + IAccessControl acl = IAccessControl(clone); + bytes32[7] memory adminRoles = _autoGrantedAdminRoles(); + for (uint256 i = 0; i < adminRoles.length; i++) { + assertTrue(acl.hasRole(adminRoles[i], initialAdmin), "impl did not auto-grant an expected admin role"); + } + // `bytes32(0)` is OZ's DEFAULT_ADMIN_ROLE — the root that admins + // every other role. `initialAdmin` must NOT hold it, else the + // seven-role renounce leaves the deployer with root regardless. + assertFalse(acl.hasRole(bytes32(0), initialAdmin), "initialAdmin unexpectedly holds DEFAULT_ADMIN_ROLE"); } - /// @notice Inverted: `assertNonAdminGrantsAbsent` reverts - /// `UnexpectedAutoGrantHeld` when a non-admin grant the mirror bundle is - /// supposed to add is already held on the supposedly-fresh clone. Mocks - /// every `hasRole` false except the first non-admin entry (index 5). - function testAssertNonAdminGrantsRejectsUnexpected() external { + /// @notice The script's own slice constants and admin-role list agree + /// with (a) the invariant map length and (b) the replica list the + /// happy path drives the sequence with — so a drift in either the + /// script's constants or the invariant map is caught here rather than + /// silently diverging from the hand-replicated happy path. + function testScriptConstantsMatchInvariantMapAndReplica() external { selectBaseFork(); - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address clone = address(0xACc0000000000000000000000000000000000002); - RoleGrant memory g = LibAuthoriserInvariants.expectedGrants()[5]; - vm.mockCall(clone, abi.encodeWithSelector(IAccessControl.hasRole.selector), abi.encode(false)); - vm.mockCall(clone, abi.encodeWithSelector(IAccessControl.hasRole.selector, g.role, g.grantee), abi.encode(true)); - vm.expectRevert(abi.encodeWithSelector(UnexpectedAutoGrantHeld.selector, clone, g.role, g.grantee)); - testable.exposed_assertNonAdminGrantsAbsent(clone); - } - - // ------------------------------------------------------------------------- - // NewClone log extraction (reached via exposed wrapper — the production - // call site reads the real factory, which always emits a matching event). - // ------------------------------------------------------------------------- - - /// @notice Inverted: `extractCloneAddressFromLogs` reverts when no NewClone - /// event from the factory is present — an invariant break on the factory. - /// The fixture takes every skip branch first (right-topic/wrong-emitter, - /// right-emitter/empty-topics, right-emitter/wrong-topic) before the - /// fall-through revert. - function testExtractCloneAddressRejectsMissingNewClone() external { - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address factory = makeAddr("factory"); - - bytes32[] memory newCloneTopic = new bytes32[](1); - newCloneTopic[0] = keccak256("NewClone(address,address,address)"); - bytes32[] memory otherTopic = new bytes32[](1); - otherTopic[0] = keccak256("SomethingElse(uint256)"); - - VmSafe.Log[] memory logs = new VmSafe.Log[](3); - // Right topic, wrong emitter -> skipped by the emitter check. - logs[0] = VmSafe.Log({topics: newCloneTopic, data: hex"", emitter: makeAddr("notFactory")}); - // Right emitter, no topics -> skipped by the topics.length check. - logs[1] = VmSafe.Log({topics: new bytes32[](0), data: hex"", emitter: factory}); - // Right emitter, wrong topic -> skipped by the topic[0] check. - logs[2] = VmSafe.Log({topics: otherTopic, data: hex"", emitter: factory}); - - vm.expectRevert(abi.encodeWithSignature("Error(string)", "DeployV4AuthoriserClone: NewClone not emitted")); - testable.exposed_extractCloneAddressFromLogs(logs, factory, makeAddr("expectedImpl")); - } - - /// @notice Inverted: `extractCloneAddressFromLogs` reverts when a NewClone - /// event is present but its `implementation` field does not match the - /// expected V4 impl — the cross-check that the clone proxies the right - /// logic. NewClone's three address args are all in `data` (no indexed args). - function testExtractCloneAddressRejectsImplMismatch() external { - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - address factory = makeAddr("factory"); - address expectedImpl = makeAddr("expectedImpl"); - address wrongImpl = makeAddr("wrongImpl"); - - bytes32[] memory topics = new bytes32[](1); - topics[0] = keccak256("NewClone(address,address,address)"); - VmSafe.Log[] memory logs = new VmSafe.Log[](1); - logs[0] = VmSafe.Log({ - topics: topics, data: abi.encode(makeAddr("sender"), wrongImpl, makeAddr("clone")), emitter: factory - }); - - vm.expectRevert(abi.encodeWithSignature("Error(string)", "DeployV4AuthoriserClone: NewClone impl mismatch")); - testable.exposed_extractCloneAddressFromLogs(logs, factory, expectedImpl); - } - - // ------------------------------------------------------------------------- - // Bundle-construction helpers (mirror the script so a tamper test can - // perturb exactly one field of an otherwise-canonical bundle). - // ------------------------------------------------------------------------- - - /// @notice The canonical deploy-bundle calldata: - /// `clone(v4Impl, abi.encode(Config(Safe)))` against the CloneFactory. - function _expectedDeployData() internal view returns (bytes memory) { - address v4Impl = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; - bytes memory initData = abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: address(safe)})); - return abi.encodeCall(ICloneableFactoryV2.clone, (v4Impl, initData)); - } - - /// @notice The canonical single-tx deploy bundle (target = CloneFactory). - function _deployTxs() internal view returns (SafeTx[] memory txs) { - txs = new SafeTx[](1); - txs[0] = SafeTx({ - to: LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS, - value: 0, - data: _expectedDeployData(), - operation: 0 - }); - } - - /// @notice The canonical six-tx grants bundle targeting `clone` — one - /// `grantRole` per non-admin entry (indices 5..10) of `expectedGrants()`. - function _grantsTxs(address clone) internal pure returns (SafeTx[] memory txs) { - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - txs = new SafeTx[](6); - for (uint256 i = 0; i < 6; i++) { - RoleGrant memory g = grants[5 + i]; - txs[i] = SafeTx({ - to: clone, value: 0, data: abi.encodeCall(IAccessControl.grantRole, (g.role, g.grantee)), operation: 0 - }); + RoleGrant[] memory allGrants = LibAuthoriserInvariants.expectedGrants(); + assertEq(harness.mirrorStartIndex(), 5, "MIRROR_START_INDEX drifted from the happy-path replica"); + assertEq(harness.mirrorCount(), 6, "MIRROR_COUNT drifted from the happy-path replica"); + assertEq( + harness.mirrorStartIndex() + harness.mirrorCount(), + allGrants.length, + "slice constants do not cover the invariant map exactly" + ); + bytes32[7] memory scriptRoles = harness.autoGrantedAdminRolesExternal(); + bytes32[7] memory replicaRoles = _autoGrantedAdminRoles(); + for (uint256 i = 0; i < scriptRoles.length; i++) { + assertEq(scriptRoles[i], replicaRoles[i], "script admin-role list drifted from the test replica"); } - } - - /// @notice Emit `txs` as a Tx Builder JSON artifact (via the same - /// `LibSafeOps.emitTxBuilderJson` the script uses, so the schema is always - /// valid) at a unique path, and return that path so a tampered/malformed - /// bundle can be fed to `verify()`. - function _writeArtifact(uint256 chainId, SafeTx[] memory txs, string memory tag) - internal - returns (string memory path) - { - string memory json = LibSafeOps.emitTxBuilderJson(address(safe), chainId, "tamper-fixture", txs); - path = string.concat(vm.projectRoot(), "/out/tamper-", tag, ".json"); - vm.writeFile(path, json); + // The live invariant map satisfies the script's own slice guard. + harness.callAssertGrantsSliceInvariant(); } } diff --git a/test/script/AuthoriserCloneAuthorization.t.sol b/test/script/AuthoriserCloneAuthorization.t.sol deleted file mode 100644 index d2aa624e..00000000 --- a/test/script/AuthoriserCloneAuthorization.t.sol +++ /dev/null @@ -1,209 +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 {VmSafe} from "forge-std-1.16.1/src/Vm.sol"; -import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; - -import {TestableDeployV4AuthoriserClone} from "./TestableDeployV4AuthoriserClone.sol"; -import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; -import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; -import { - StoxOffchainAssetReceiptVaultAuthorizerV1 -} from "../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; -import {SCHEDULE_CORPORATE_ACTION} from "../../src/lib/LibCorporateAction.sol"; -import {ICloneableFactoryV2} from "rain-factory-0.1.1/src/interface/ICloneableFactoryV2.sol"; -import {LibCloneFactoryDeploy} from "rain-factory-0.1.1/src/lib/LibCloneFactoryDeploy.sol"; -import {IAuthorizeV1, Unauthorized} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; -import {DEPOSIT} from "rain-vats-0.1.6/src/concrete/vault/OffchainAssetReceiptVault.sol"; -import { - DEPOSIT_ADMIN, - OffchainAssetReceiptVaultAuthorizerV1Config -} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; - -/// @title AuthoriserCloneAuthorizationTest -/// @notice The clone the deploy + grants bundles produce is a functioning -/// authoriser: it authorizes every operation the grants intend, denies -/// un-granted callers, and the Safe admin the deploy bundle installs can -/// onboard new operators — including the corporate-action roles the Stox -/// override adds. -contract AuthoriserCloneAuthorizationTest is Test { - IGnosisSafe internal safe; - - /// @notice Fork Base, deploy a clone via `run()`, and mirror the six - /// non-admin grants so the clone holds the full production grant set. - function _deployAndMirror() internal returns (address clone) { - vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - - StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); - vm.etch(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, address(impl).code); - - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - testable.run(); - clone = testable.lastPredictedClone(); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - testable.mirrorGrants(); - } - - /// @notice Every operational grant authorizes its grantee: for each - /// (grantee, permission) in the mirrored slice of `expectedGrants()`, the - /// clone's `authorize` returns rather than reverting `Unauthorized`. - function testMirroredGrantsAuthorizeTheirOperations() external { - address clone = _deployAndMirror(); - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 5; i < grants.length; i++) { - IAuthorizeV1(clone).authorize(grants[i].grantee, grants[i].role, hex""); - } - } - - /// @notice An address holding no operational role is denied: `authorize` - /// reverts `Unauthorized` for DEPOSIT. - function testUngrantedAddressIsDeniedDeposit() external { - address clone = _deployAndMirror(); - address nobody = makeAddr("nobody"); - vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector, nobody, DEPOSIT, hex"")); - IAuthorizeV1(clone).authorize(nobody, DEPOSIT, hex""); - } - - /// @notice The Safe admin the deploy bundle installs can onboard a new - /// DEPOSIT operator, who is then authorized to deposit. - function testSafeAdminCanOnboardADepositOperator() external { - address clone = _deployAndMirror(); - address operator = makeAddr("operator"); - vm.prank(address(safe)); - IAccessControl(clone).grantRole(DEPOSIT, operator); - IAuthorizeV1(clone).authorize(operator, DEPOSIT, hex""); - } - - /// @notice A caller without DEPOSIT_ADMIN cannot grant DEPOSIT: `grantRole` - /// reverts naming DEPOSIT_ADMIN as the missing role. - function testNonAdminCannotGrantDeposit() external { - address clone = _deployAndMirror(); - address attacker = makeAddr("attacker"); - vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, attacker, DEPOSIT_ADMIN) - ); - IAccessControl(clone).grantRole(DEPOSIT, makeAddr("victim")); - } - - /// @notice The corporate-action admin the Stox override adds is functional: - /// the Safe, holding SCHEDULE_CORPORATE_ACTION_ADMIN, can grant - /// SCHEDULE_CORPORATE_ACTION to a scheduler who is then authorized for it. - function testSafeAdminCanOnboardACorporateActionScheduler() external { - address clone = _deployAndMirror(); - address scheduler = makeAddr("scheduler"); - vm.prank(address(safe)); - IAccessControl(clone).grantRole(SCHEDULE_CORPORATE_ACTION, scheduler); - IAuthorizeV1(clone).authorize(scheduler, SCHEDULE_CORPORATE_ACTION, hex""); - } - - /// @notice The six-tx grants bundle: one `grantRole` per non-admin entry - /// (indices 5..10) of `expectedGrants()`. - function _grantsTxs(address clone) internal pure returns (SafeTx[] memory txs) { - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - txs = new SafeTx[](6); - for (uint256 i = 0; i < 6; i++) { - RoleGrant memory g = grants[5 + i]; - txs[i] = SafeTx({ - to: clone, value: 0, data: abi.encodeCall(IAccessControl.grantRole, (g.role, g.grantee)), operation: 0 - }); - } - } - - /// @notice Approve `hash` from the first `threshold` owners and return the - /// ascending packed approved-hash signature blob Safe expects. - function _thresholdSigs(bytes32 hash) internal returns (bytes memory) { - uint256 threshold = safe.getThreshold(); - address[] memory owners = safe.getOwners(); - address[] memory approvers = new address[](threshold); - for (uint256 i = 0; i < threshold; i++) { - approvers[i] = owners[i]; - vm.prank(owners[i]); - safe.approveHash(hash); - } - return LibSafeOps.packApprovedHashSignatures(LibSafeOps.sortAddressesAscending(approvers), threshold); - } - - /// @notice Extract the clone address from the factory's `NewClone` event. - function _cloneFromLogs(VmSafe.Log[] memory logs, address factory, address expectedImpl) - internal - pure - returns (address) - { - bytes32 sig = keccak256("NewClone(address,address,address)"); - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].emitter != factory) continue; - if (logs[i].topics.length == 0 || logs[i].topics[0] != sig) continue; - (, address implFromEvent, address cloneFromEvent) = abi.decode(logs[i].data, (address, address, address)); - require(implFromEvent == expectedImpl, "unexpected impl in NewClone"); - return cloneFromEvent; - } - revert("NewClone not emitted"); - } - - /// @notice End-to-end: the Safe owners sign and submit the deploy bundle as - /// a real `execTransaction` to deploy the clone, then sign and submit the - /// grants MultiSend bundle at the next nonce, and the clone the signed - /// bundles produce authorizes the mirrored operations and denies an - /// un-granted caller. The whole path runs through `checkSignatures`, not a - /// prank of the Safe. - function testEndToEndSignedBundlesProduceAWorkingAuthoriser() external { - vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); - address v4Impl = LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1; - vm.etch(v4Impl, address(impl).code); - address factory = LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS; - - // 1. Deploy bundle: a signed direct execTransaction to the CloneFactory. - bytes memory deployData = abi.encodeCall( - ICloneableFactoryV2.clone, - (v4Impl, abi.encode(OffchainAssetReceiptVaultAuthorizerV1Config({initialAdmin: address(safe)}))) - ); - bytes32 deployHash = LibSafeOps.computeSafeTxHashViaSafe( - safe, SafeTx({to: factory, value: 0, data: deployData, operation: 0}), safe.nonce() - ); - bytes memory deploySigs = _thresholdSigs(deployHash); - vm.recordLogs(); - bool okDeploy = - safe.execTransaction(factory, 0, deployData, 0, 0, 0, 0, address(0), payable(address(0)), deploySigs); - assertTrue(okDeploy, "deploy bundle executed"); - address clone = _cloneFromLogs(vm.getRecordedLogs(), factory, v4Impl); - - // 2. Grants bundle: a signed MultiSend execTransaction at the next nonce. - SafeTx[] memory grants = _grantsTxs(clone); - bytes32 grantsHash = LibSafeOps.computeMultiSendSafeTxHash(safe, grants, safe.nonce()); - bytes memory grantsSigs = _thresholdSigs(grantsHash); - bool okGrants = safe.execTransaction( - LibSafeOps.MULTISEND_CALL_ONLY_1_4_1, - 0, - LibSafeOps.encodeMultiSend(grants), - 1, - 0, - 0, - 0, - address(0), - payable(address(0)), - grantsSigs - ); - assertTrue(okGrants, "grants bundle executed"); - - // 3. The clone the signed bundles produced authorizes the mirrored - // operations and denies an un-granted caller. - RoleGrant[] memory all = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 5; i < all.length; i++) { - IAuthorizeV1(clone).authorize(all[i].grantee, all[i].role, hex""); - } - address nobody = makeAddr("nobody"); - vm.expectRevert(abi.encodeWithSelector(Unauthorized.selector, nobody, DEPOSIT, hex"")); - IAuthorizeV1(clone).authorize(nobody, DEPOSIT, hex""); - } -} diff --git a/test/script/DeployV4AuthoriserCloneHarness.sol b/test/script/DeployV4AuthoriserCloneHarness.sol new file mode 100644 index 00000000..c4350d65 --- /dev/null +++ b/test/script/DeployV4AuthoriserCloneHarness.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeployV4AuthoriserClone} from "../../script/20260619-deploy-v4-authoriser-clone.s.sol"; + +/// @title DeployV4AuthoriserCloneHarness +/// @notice Subclass of the deploy script that exposes its `internal` +/// post-state assertion as `external` so `vm.expectRevert` can intercept +/// the typed errors it raises. Mirrors the `MigrateBeaconOwnersHarness` +/// pattern — the state-changing sequence in `run()` is exercised via +/// `vm.prank(deployer)` inline in the tests (`vm.startBroadcast` is +/// mutually exclusive with `vm.prank` in `forge test`, so tests can't call +/// `run()` directly through a prank). +contract DeployV4AuthoriserCloneHarness is DeployV4AuthoriserClone { + function callAssertPostState(address clone, address deployer, address v4Impl) external view { + _assertPostState(clone, deployer, v4Impl); + } + + /// @notice Exposes the script's non-admin grant slice start so tests + /// can pin the hand-replicated happy-path sequence to the constant the + /// script actually slices with. + function mirrorStartIndex() external pure returns (uint256) { + return MIRROR_START_INDEX; + } + + /// @notice Exposes the script's non-admin grant slice length. + function mirrorCount() external pure returns (uint256) { + return MIRROR_COUNT; + } + + /// @notice Exposes the script's hand-listed auto-granted admin roles so + /// tests can assert the replica list they drive the sequence with has + /// not drifted from the script's own list. + function autoGrantedAdminRolesExternal() external pure returns (bytes32[7] memory) { + return autoGrantedAdminRoles(); + } + + /// @notice Exposes the slice-invariant guard so tests can assert it + /// passes against the live invariant map. + function callAssertGrantsSliceInvariant() external pure { + assertGrantsSliceInvariant(); + } +} diff --git a/test/script/GrantsBundleSafeTxHash.t.sol b/test/script/GrantsBundleSafeTxHash.t.sol deleted file mode 100644 index c3195d03..00000000 --- a/test/script/GrantsBundleSafeTxHash.t.sol +++ /dev/null @@ -1,120 +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 {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; - -import {TestableDeployV4AuthoriserClone} from "./TestableDeployV4AuthoriserClone.sol"; -import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; -import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; -import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol"; -import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol"; -import { - StoxOffchainAssetReceiptVaultAuthorizerV1 -} from "../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; - -/// @title GrantsBundleSafeTxHashTest -/// @notice The SafeTxHash the deploy script logs for the six-tx grants bundle -/// is the hash the live Safe requires to execute that bundle. The Safe -/// Transaction Builder submits a batch as a single `MultiSendCallOnly` -/// delegatecall at one nonce, so signing the logged hash authorizes the whole -/// bundle and it lands atomically. -contract GrantsBundleSafeTxHashTest is Test { - IGnosisSafe internal safe; - - /// @notice Fork Base, etch the V4 impl runtime at its pin (the impl is not - /// yet deployed on Base) so `run()`'s pre-flight passes, then `run()` to - /// deploy a clone on the fork and wire the testable overrides to it. - function _setUpForkAndClone() internal returns (address clone) { - vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); - - StoxOffchainAssetReceiptVaultAuthorizerV1 impl = new StoxOffchainAssetReceiptVaultAuthorizerV1(); - vm.etch(LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, address(impl).code); - - TestableDeployV4AuthoriserClone testable = new TestableDeployV4AuthoriserClone(); - testable.run(); - clone = testable.lastPredictedClone(); - testable.setResolvedClone(clone); - testable.setResolvedCloneCodehash(clone.codehash); - } - - /// @notice The six-tx grants bundle the script emits: one `grantRole` per - /// non-admin entry (indices 5..10) of `expectedGrants()`. - function _grantsTxs(address clone) internal pure returns (SafeTx[] memory txs) { - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - txs = new SafeTx[](6); - for (uint256 i = 0; i < 6; i++) { - RoleGrant memory g = grants[5 + i]; - txs[i] = SafeTx({ - to: clone, value: 0, data: abi.encodeCall(IAccessControl.grantRole, (g.role, g.grantee)), operation: 0 - }); - } - } - - /// @notice Approve `hash` from the first `threshold` owners and return the - /// ascending packed approved-hash signature blob Safe expects. - function _thresholdSigs(bytes32 hash) internal returns (bytes memory) { - uint256 threshold = safe.getThreshold(); - address[] memory owners = safe.getOwners(); - address[] memory approvers = new address[](threshold); - for (uint256 i = 0; i < threshold; i++) { - approvers[i] = owners[i]; - vm.prank(owners[i]); - safe.approveHash(hash); - } - return LibSafeOps.packApprovedHashSignatures(LibSafeOps.sortAddressesAscending(approvers), threshold); - } - - /// @notice Threshold owners signing the SafeTxHash the script logs for the - /// grants bundle authorize its execution: the Safe runs the batch as one - /// `MultiSendCallOnly` delegatecall, consuming a single nonce, and all six - /// grants land on the clone. - function testGrantsBundleExecutesViaItsLoggedSafeTxHash() external { - address clone = _setUpForkAndClone(); - SafeTx[] memory txs = _grantsTxs(clone); - uint256 nonce = safe.nonce(); - - bytes32 loggedHash = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); - bytes memory sigs = _thresholdSigs(loggedHash); - bool ok = safe.execTransaction( - LibSafeOps.MULTISEND_CALL_ONLY_1_4_1, - 0, - LibSafeOps.encodeMultiSend(txs), - 1, - 0, - 0, - 0, - address(0), - payable(address(0)), - sigs - ); - assertTrue(ok, "batch executed via the logged SafeTxHash"); - assertEq(safe.nonce(), nonce + 1, "batch consumed exactly one nonce"); - - RoleGrant[] memory grants = LibAuthoriserInvariants.expectedGrants(); - for (uint256 i = 0; i < 6; i++) { - assertTrue( - IAccessControl(clone).hasRole(grants[5 + i].role, grants[5 + i].grantee), - "grant landed via the single multiSend" - ); - } - } - - /// @notice The logged SafeTxHash binds to every transaction in the bundle: - /// changing any grant's target flips the hash. - function testTamperingAGrantChangesTheLoggedSafeTxHash() external { - address clone = _setUpForkAndClone(); - SafeTx[] memory txs = _grantsTxs(clone); - uint256 nonce = safe.nonce(); - - bytes32 original = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); - txs[3].to = makeAddr("tampered"); - bytes32 tampered = LibSafeOps.computeMultiSendSafeTxHash(safe, txs, nonce); - - assertTrue(original != tampered, "tampering a grant must change the SafeTxHash"); - } -} diff --git a/test/script/TestableDeployV4AuthoriserClone.sol b/test/script/TestableDeployV4AuthoriserClone.sol deleted file mode 100644 index ef8347ce..00000000 --- a/test/script/TestableDeployV4AuthoriserClone.sol +++ /dev/null @@ -1,109 +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 {DeployV4AuthoriserClone} from "../../script/20260619-deploy-v4-authoriser-clone.s.sol"; -import {VmSafe} from "forge-std-1.16.1/src/Vm.sol"; - -/// @title TestableDeployV4AuthoriserClone -/// @notice Test scaffolding around `DeployV4AuthoriserClone` that lets the -/// suite inject a simulated post-hydrate clone address into -/// `_resolveClone()`. Production code reads -/// `LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE` directly, which lives in -/// library bytecode (not storage), so `vm.store` cannot move the pin. The -/// subclass-override pattern sidesteps that: tests instantiate this contract -/// instead of the bare script, call `setResolvedClone(addr)` to pre-load the -/// pin, and the script's `mirrorGrants()` / `verify()` happily reads the -/// injected value via the overridden `_resolveClone()`. -/// @dev Kept in a sibling `.sol` file (not `.t.sol`) and not inheriting from -/// `forge-std`'s `Test`, so it counts as zero test contracts and avoids -/// breaking the rainix one-contract-per-`.t.sol`-file convention enforced -/// by `rainix-sol-single-contract`. The test file imports this contract -/// rather than declaring it locally. -contract TestableDeployV4AuthoriserClone is DeployV4AuthoriserClone { - /// @notice The address `_resolveClone()` returns. Defaults to - /// `address(0)` so a freshly-instantiated subclass behaves identically - /// to the un-overridden script (i.e. trips `V4AuthoriserCloneNotPinned` - /// on the first `mirrorGrants()` / grants-branch `verify()` call). - address public resolvedClone; - - /// @notice The codehash `_resolveCloneCodehash()` returns. Defaults to - /// `bytes32(0)` (the lib pin's compile-time value); tests that need a - /// passing codehash check call `setResolvedCloneCodehash` to inject - /// the actual EIP-1167 codehash of the fork-deployed clone. - bytes32 public resolvedCloneCodehash; - - /// @notice The predicted clone address from the most recent `run()` - /// invocation, captured via `_recordPredictedClone()`. Defaults to - /// `address(0)` until `run()` is called. - address public lastPredictedClone; - - /// @notice Inject a simulated post-hydrate clone address. Tests call - /// this before invoking `mirrorGrants()` / `verify()` to simulate the - /// state of the world after the post-execution pin PR has merged. - /// @param clone The address `_resolveClone()` should subsequently - /// return. - function setResolvedClone(address clone) external { - resolvedClone = clone; - } - - /// @notice Inject a simulated post-hydrate clone codehash. Tests that - /// exercise the happy-path call this with the captured clone's actual - /// codehash so the pre-flight codehash check passes; tests that - /// exercise the codehash-mismatch revert leave it at the default - /// `bytes32(0)` and rely on a real (non-zero) codehash at the - /// resolved address. - /// @param codehash The codehash `_resolveCloneCodehash()` should - /// subsequently return. - function setResolvedCloneCodehash(bytes32 codehash) external { - resolvedCloneCodehash = codehash; - } - - /// @inheritdoc DeployV4AuthoriserClone - function _resolveClone() internal view override returns (address) { - return resolvedClone; - } - - /// @inheritdoc DeployV4AuthoriserClone - function _resolveCloneCodehash() internal view override returns (bytes32) { - return resolvedCloneCodehash; - } - - /// @inheritdoc DeployV4AuthoriserClone - function _recordPredictedClone(address predictedClone) internal override { - lastPredictedClone = predictedClone; - } - - /// @notice Test-only wrapper exposing the internal `assertCloneCodehash` - /// so its `CloneCodehashMismatch` revert path can be exercised directly — - /// the production call site in `run()` always sees a correctly-shaped - /// simulated clone, so the branch is otherwise unreachable. - function exposed_assertCloneCodehash(address clone, bytes32 expected) external view { - assertCloneCodehash(clone, expected); - } - - /// @notice Test-only wrapper exposing the internal `assertAutoGrantsHeld` - /// so its `AutoGrantMissing` revert path can be exercised directly. - function exposed_assertAutoGrantsHeld(address clone, address admin) external view { - assertAutoGrantsHeld(clone, admin); - } - - /// @notice Test-only wrapper exposing the internal - /// `assertNonAdminGrantsAbsent` so its `UnexpectedAutoGrantHeld` revert - /// path can be exercised directly. - function exposed_assertNonAdminGrantsAbsent(address clone) external view { - assertNonAdminGrantsAbsent(clone); - } - - /// @notice Test-only wrapper exposing the internal - /// `extractCloneAddressFromLogs` so its NewClone-absent and impl-mismatch - /// revert paths can be exercised with hand-built logs (the production call - /// site reads the real factory, which always emits a matching event). - function exposed_extractCloneAddressFromLogs(VmSafe.Log[] memory logs, address factory, address expectedImpl) - external - pure - returns (address) - { - return extractCloneAddressFromLogs(logs, factory, expectedImpl); - } -} diff --git a/test/src/lib/LibProdDeployV4.t.sol b/test/src/lib/LibProdDeployV4.t.sol index 47b49a25..abf5b444 100644 --- a/test/src/lib/LibProdDeployV4.t.sol +++ b/test/src/lib/LibProdDeployV4.t.sol @@ -4,6 +4,7 @@ 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 {ERC1167_PREFIX, ERC1167_SUFFIX} from "rain-extrospection-0.1.1/src/lib/LibExtrospectERC1167Proxy.sol"; import {LibProdDeployV4} from "../../../src/lib/LibProdDeployV4.sol"; import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; @@ -393,21 +394,34 @@ contract LibProdDeployV4Test is Test { assertEq(LibProdDeployV4.DEPLOY_TAG, "0_1_3"); } - /// The V4 authoriser clone is a non-deterministic deploy target and is - /// still a placeholder. Asserting the placeholder explicitly prevents it - /// from being silently shipped as a real pin, and makes this test fail - /// (prompting a real address + codehash assertion) the moment the clone is - /// hydrated with its deployed literal. + /// The V4 authoriser clone ADDRESS is a non-deterministic deploy target + /// and is still a placeholder. Asserting the placeholder explicitly + /// prevents it from being silently shipped as a real pin, and makes + /// this test fail (prompting a real address assertion) the moment the + /// clone is hydrated with its deployed literal. function testAuthoriserV4ClonePlaceholder() external pure { assertEq( LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE, address(0), - "clone hydrated: replace this placeholder guard with a real address + codehash assertion" + "clone hydrated: replace this placeholder guard with a real address assertion" + ); + } + + /// The V4 authoriser clone CODEHASH is deterministic ahead of the deploy + /// — the EIP-1167 runtime with the pinned V4 impl address embedded — so + /// unlike the address it is hydrated before the broadcast. Re-derive it + /// from the impl pin and assert the literal matches, so an impl-address + /// change can't leave a stale codehash behind. + function testAuthoriserV4CloneCodehashMatchesImplDerivation() external pure { + bytes32 derived = keccak256( + abi.encodePacked( + ERC1167_PREFIX, LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_0_1_1, ERC1167_SUFFIX + ) ); assertEq( LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH, - bytes32(0), - "clone codehash hydrated: replace this placeholder guard with a real assertion" + derived, + "clone codehash literal drifted from the EIP-1167(impl) derivation" ); }