diff --git a/.github/workflows/grant-issuer-mint-burn-artifact.yaml b/.github/workflows/grant-issuer-mint-burn-artifact.yaml new file mode 100644 index 00000000..8643a3c1 --- /dev/null +++ b/.github/workflows/grant-issuer-mint-burn-artifact.yaml @@ -0,0 +1,59 @@ +name: grant-issuer-mint-burn-artifact +on: + workflow_dispatch: + pull_request: + paths: + - 'script/20260630-grant-issuer-mint-burn.s.sol' + - 'src/lib/LibSafeOps.sol' + - 'src/lib/LibInvariants.sol' + - 'src/lib/LibSafeInvariants.sol' + - 'src/lib/LibAuthoriserInvariants.sol' + - 'src/lib/LibTokenInvariants.sol' + - 'src/interface/IGnosisSafe.sol' + - 'src/interface/IAuthorisable.sol' + - '.github/workflows/grant-issuer-mint-burn-artifact.yaml' +# Builds the two Safe Tx Builder JSON bundles (DEPOSIT/mint + WITHDRAW/burn) +# that grant the new issuer EOA mint/burn authority on the shared authoriser. +# Each grant is authored in its OWN `forge script` invocation: the script's +# n+1 reversibility check advances the Safe nonce, so chaining both sigs in +# one process would compute the second bundle's hash at the wrong nonce. +# `workflow_dispatch` lets a maintainer produce the bundles for live +# submission; the paths-filtered `pull_request` trigger runs the dry-run as a +# sanity check whenever the script or its invariant dependencies change. Both +# use the same unpinned Base head fork as the script's tests, so any drift in +# the Safe config, vault ownership/authoriser uniformity, or pinned authoriser +# grants trips the pre-flight here before a signer ever sees a bundle. +jobs: + build-artifact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@v17 + - name: Install Soldeer dependencies + run: nix develop --command forge soldeer install + - name: Build DEPOSIT (mint) grant bundle (dry-run) + env: + BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }} + run: | + nix develop --command bash -c ' + forge script script/20260630-grant-issuer-mint-burn.s.sol \ + --sig "grantDeposit()" \ + --rpc-url base \ + --no-storage-caching + ' + - name: Build WITHDRAW (burn) grant bundle (dry-run) + env: + BASE_RPC_URL: ${{ secrets.RPC_URL_BASE_FORK }} + run: | + nix develop --command bash -c ' + forge script script/20260630-grant-issuer-mint-burn.s.sol \ + --sig "grantWithdraw()" \ + --rpc-url base \ + --no-storage-caching + ' + - name: Upload Tx Builder JSON bundles + uses: actions/upload-artifact@v4 + with: + name: grant-issuer-mint-burn-tx-builder-json + path: out/*.json + if-no-files-found: error diff --git a/script/20260630-grant-issuer-mint-burn.s.sol b/script/20260630-grant-issuer-mint-burn.s.sol new file mode 100644 index 00000000..d2d144c0 --- /dev/null +++ b/script/20260630-grant-issuer-mint-burn.s.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; + +import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibInvariants} from "../src/lib/LibInvariants.sol"; +import {LibAuthoriserInvariants} from "../src/lib/LibAuthoriserInvariants.sol"; +import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; + +/// @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 on-chain state at +/// verification time. +/// @param field The name of the field that drifted (e.g. `"chainId"`, `"to"`, +/// `"data"`, `"safeTxHash"`). +error VerifyMismatch(string field); + +/// @notice The verify pre-flight got a non-1 tx count from the artifact. Each +/// grant bundle is a single-tx bundle (DEPOSIT and WITHDRAW are authored as +/// two independent bundles because `LibSafeOps` hashes a single `SafeTx`, not +/// a MultiSend batch); a multi-tx artifact at this path is unambiguous drift. +/// @param actualCount The number of transactions in the parsed artifact. +error VerifyExpectedSingleTx(uint256 actualCount); + +/// @notice The issuer already holds the role the bundle would grant. The +/// bundle is a one-shot onboarding step: if the grant is already live the +/// pre-state has drifted from what the bundle assumes, and authoring it would +/// emit a no-op that wastes a signing ceremony. +/// @param role The role the issuer already holds. +error IssuerAlreadyHoldsRole(bytes32 role); + +/// @notice The simulated `grantRole` did not leave the issuer holding the +/// role. A defensive post-state assertion: if the authoriser's `grantRole` +/// silently no-ops (e.g. an unexpected impl behind the clone) this trips +/// before the artifact is trusted. +/// @param role The role that failed to apply. +error GrantNotApplied(bytes32 role); + +/// @title GrantIssuerMintBurn +/// @notice **PENDING.** Authors the ST0x token-owner Safe bundles that grant +/// the new issuer EOA `0x3d0C…E8aE` the `DEPOSIT` (mint) and `WITHDRAW` +/// (burn) roles on the live shared authoriser. Because every production +/// receipt vault shares this one authoriser and its role checks are not +/// vault-scoped, the two grants enable the issuer to mint and burn across +/// every ST0x SFT at once. The pre-flight asserts that uniformity +/// (`LibInvariants.assertAll` runs `assertUniformAuthoriser`) so the "every +/// SFT" guarantee is proven against the live chain before a signer sees a +/// bundle. +/// @dev `DEPOSIT` and `WITHDRAW` are authored as **two single-tx bundles**, +/// not one batch: `LibSafeOps.computeSafeTxHashViaSafe` hashes a single +/// `SafeTx`, whereas the Safe Tx Builder UI MultiSends a multi-tx bundle into +/// one delegatecall and signs that — so a 2-tx bundle's per-tx hash would not +/// match what signers verify. The grants are independent (a half-applied +/// state is benign and revocable), so two bundles is the faithful shape. +/// +/// Four entrypoints, two per role: +/// - `grantDeposit()` / `grantWithdraw()`: dry-run + emit Safe Tx Builder +/// JSON + log the canonical SafeTxHash. Each MUST run in its own `forge +/// script` process — the n+1 reversibility check advances the Safe nonce, +/// so chaining both in one process would compute the second artifact's hash +/// at the wrong nonce. +/// - `verifyDeposit(string)` / `verifyWithdraw(string)`: re-run the pre-flight +/// and assert an existing artifact matches what the live pre-flight emits. +contract GrantIssuerMintBurn is Script { + /// @notice The new issuer EOA being granted mint + burn authority. + /// Hardcoded literal: the bundle is a one-shot onboarding step for this + /// specific operator, not a parameterised utility. + /// https://basescan.org/address/0x3d0cd66efa66c05d86c3d4316b03eae87ab9e8ae + address internal constant ISSUER = 0x3d0CD66EFA66c05d86c3d4316B03eAE87ab9E8aE; + + /// @notice The mint role on the OffchainAssetReceiptVault authoriser. + /// `DEPOSIT` gates issuing (minting) shares + receipts. Derived the same + /// way `LibAuthoriserInvariants.expectedGrants` derives it so the literals + /// cannot drift apart. + bytes32 internal constant DEPOSIT = keccak256("DEPOSIT"); + + /// @notice The burn role on the authoriser. `WITHDRAW` gates redeeming + /// (burning) shares + receipts. + bytes32 internal constant WITHDRAW = keccak256("WITHDRAW"); + + /// @notice Signer-visible `meta.name` for the DEPOSIT (mint) bundle. + string internal constant DEPOSIT_BUNDLE_NAME = "ST0x authoriser - grant DEPOSIT (mint) to new issuer"; + + /// @notice Signer-visible `meta.name` for the WITHDRAW (burn) bundle. + string internal constant WITHDRAW_BUNDLE_NAME = "ST0x authoriser - grant WITHDRAW (burn) to new issuer"; + + /// @notice Output path for the DEPOSIT (mint) grant bundle. `out/` is + /// repo-wide read-write in `foundry.toml` `fs_permissions`. + string internal constant DEPOSIT_ARTIFACT_PATH = "out/grant-issuer-deposit.json"; + + /// @notice Output path for the WITHDRAW (burn) grant bundle. + string internal constant WITHDRAW_ARTIFACT_PATH = "out/grant-issuer-withdraw.json"; + + /// @notice Dry-run + author the DEPOSIT (mint) grant bundle. + function grantDeposit() external { + _authorGrant(DEPOSIT, DEPOSIT_BUNDLE_NAME, DEPOSIT_ARTIFACT_PATH); + } + + /// @notice Dry-run + author the WITHDRAW (burn) grant bundle. + function grantWithdraw() external { + _authorGrant(WITHDRAW, WITHDRAW_BUNDLE_NAME, WITHDRAW_ARTIFACT_PATH); + } + + /// @notice Verify a pre-emitted DEPOSIT (mint) grant artifact. + /// @param jsonPath Filesystem path to the Tx Builder JSON to verify. + function verifyDeposit(string calldata jsonPath) external view { + _verifyGrant(DEPOSIT, jsonPath); + } + + /// @notice Verify a pre-emitted WITHDRAW (burn) grant artifact. + /// @param jsonPath Filesystem path to the Tx Builder JSON to verify. + function verifyWithdraw(string calldata jsonPath) external view { + _verifyGrant(WITHDRAW, jsonPath); + } + + /// @notice Shared authoring path for a single role grant. Mirrors the + /// `MigrateMultisigThreshold.run()` shape: pre-flight, build the inner tx, + /// compute the canonical hash at the current nonce, simulate, re-assert + /// post-state, emit the artifact, then prove the grant is reversible. + /// @param role The role to grant the issuer. + /// @param bundleName The signer-visible `meta.name`. + /// @param artifactPath The `out/` path to write the bundle to. + function _authorGrant(bytes32 role, string memory bundleName, string memory artifactPath) internal { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + address authoriser = LibAuthoriserInvariants.STOX_PROD_AUTHORISER; + + // (1) Pre-flight. The orchestrator asserts Safe identity / config, + // uniform vault ownership, uniform authoriser (every production + // vault's `authorizer()` == STOX_PROD_AUTHORISER — the proof the + // grant reaches every SFT), and the pinned authoriser role grants + // (including the Safe holding `_ADMIN`, which is what lets the + // Safe grant `role` at all). + LibInvariants.assertAll(safe); + + // (1b) Idempotency guard: the bundle must change state. If the issuer + // already holds the role the pre-state has drifted; abort rather than + // author a no-op. + if (IAccessControl(authoriser).hasRole(role, ISSUER)) revert IssuerAlreadyHoldsRole(role); + + // (2) Build the single-tx bundle: Safe -> authoriser.grantRole(role, + // issuer). `abi.encodeCall` type-checks the selector + args. + SafeTx memory txn = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (role, ISSUER)), + operation: 0 + }); + + // (3) Capture the nonce before any simulation. `simulateExternalCall` + // does not advance the nonce, so the hash binds to the current Safe + // state the signer will see. + uint256 nonce = safe.nonce(); + bytes32 safeTxHash = LibSafeOps.computeSafeTxHashViaSafe(safe, txn, nonce); + + // (4) Simulate the Safe-originated call into the authoriser, mutating + // the fork to the post-grant state. The pre-flight already proved the + // Safe holds `_ADMIN`, so this grant succeeds exactly as the + // live `execTransaction` would. + LibSafeOps.simulateExternalCall(safe, authoriser, txn.data); + + // (5) Post-state: the grant applied, and every pinned authoriser + // invariant still holds (the new grant is additive — it removes + // nothing the invariant pins). + if (!IAccessControl(authoriser).hasRole(role, ISSUER)) revert GrantNotApplied(role); + LibAuthoriserInvariants.assertAll(); + + // (6) Emit the Tx Builder JSON and log it with greppable markers. + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = txn; + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, bundleName, txs); + vm.writeFile(artifactPath, 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); + + // (7) n+1 reversibility: prove the grant is not a dead end by + // executing the inverse `revokeRole(role, issuer)` under the live + // threshold (and proving an undersigned attempt reverts GS020). + // Sequenced AFTER the artifact emission so the bundle reflects the + // forward grant; the revoke exists only as a fork-local simulation. + LibSafeOps.simulateNPlus1( + safe, authoriser, abi.encodeCall(IAccessControl.revokeRole, (role, ISSUER)), safe.getThreshold() + ); + require( + !IAccessControl(authoriser).hasRole(role, ISSUER), + "GrantIssuerMintBurn: n+1 reversibility did not revoke the role" + ); + console2.log("n+1 reversibility check passed: role revoked"); + } + + /// @notice Shared verify path. Re-runs the pre-flight, parses the + /// artifact, and asserts every field matches what the live pre-flight + /// would emit. Typed `VerifyMismatch(field)` on the first divergence. + /// @param role The role the artifact is expected to grant. + /// @param jsonPath Filesystem path to the Tx Builder JSON to verify. + function _verifyGrant(bytes32 role, string memory jsonPath) internal view { + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + address authoriser = LibAuthoriserInvariants.STOX_PROD_AUTHORISER; + + // Same pre-flight as the authoring path. If the live state has drifted + // since the artifact was authored, the typed error bubbles before we + // open the file. + LibInvariants.assertAll(safe); + + (uint256 parsedChainId, address parsedTarget, SafeTx[] memory parsedTxs) = + LibSafeOps.parseTxBuilderJson(jsonPath); + + if (parsedChainId != block.chainid) revert VerifyMismatch("chainId"); + if (parsedTxs.length != 1) revert VerifyExpectedSingleTx(parsedTxs.length); + // `parseTxBuilderJson` reports `parsedTarget` as `transactions[0].to`; + // for a grant bundle that is the authoriser, not the Safe. + if (parsedTarget != authoriser) revert VerifyMismatch("to"); + + SafeTx memory expected = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (role, ISSUER)), + operation: 0 + }); + + 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"); + + // Cross-check the artifact's implied SafeTxHash against the live Safe's + // hash builder at the current nonce. A nonce bump (some other Safe tx + // executed in between) flags here, which is the desired staleness + // signal. + bytes32 liveHash = LibSafeOps.computeSafeTxHashViaSafe(safe, expected, safe.nonce()); + bytes32 artifactHash = LibSafeOps.computeSafeTxHashViaSafe(safe, parsedTxs[0], safe.nonce()); + if (liveHash != artifactHash) revert VerifyMismatch("safeTxHash"); + } +} diff --git a/test/script/20260630-grant-issuer-mint-burn.t.sol b/test/script/20260630-grant-issuer-mint-burn.t.sol new file mode 100644 index 00000000..acdea593 --- /dev/null +++ b/test/script/20260630-grant-issuer-mint-burn.t.sol @@ -0,0 +1,246 @@ +// 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 { + GrantIssuerMintBurn, + VerifyMismatch, + VerifyExpectedSingleTx, + IssuerAlreadyHoldsRole +} from "../../script/20260630-grant-issuer-mint-burn.s.sol"; +import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; +import {IAuthorisable} from "../../src/interface/IAuthorisable.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; +import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; +import {LibAuthoriserInvariants, ExpectedGrantMissing} from "../../src/lib/LibAuthoriserInvariants.sol"; +import { + LibTokenInvariants, + IOwnable, + ReceiptVaultOwnerMismatch, + ReceiptVaultAuthoriserMismatch +} from "../../src/lib/LibTokenInvariants.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol"; + +/// @title GrantIssuerMintBurnTest +/// @notice End-to-end fork tests for the issuer mint/burn grant script. +/// Covers the happy-path authoring of both single-tx grant bundles, the +/// `grant -> verify` round-trip, the inverted pre-flight invariants (each +/// mocked in isolation), and inverted `verify` rejections. Mirrors the +/// `MigrateMultisigThresholdTest` shape; per the runbook, domain-lib +/// invariants are exercised in their own lib test suites and only this +/// script's bundle-emitting + assertion logic is tested here. +contract GrantIssuerMintBurnTest is Test { + /// @notice The script under test, deployed fresh per fork. + GrantIssuerMintBurn internal script; + + /// @notice Live Safe handle. + IGnosisSafe internal safe; + + /// @notice The live shared authoriser the grants target. + address internal authoriser; + + /// @notice The issuer EOA being granted mint + burn. Mirrors the + /// `GrantIssuerMintBurn.ISSUER` literal (internal constants aren't + /// reachable from the test; kept in sync by hand). + address internal constant ISSUER = 0x3d0CD66EFA66c05d86c3d4316B03eAE87ab9E8aE; + + /// @notice Mint role. Mirrors `GrantIssuerMintBurn.DEPOSIT`. + bytes32 internal constant DEPOSIT = keccak256("DEPOSIT"); + + /// @notice Burn role. Mirrors `GrantIssuerMintBurn.WITHDRAW`. + bytes32 internal constant WITHDRAW = keccak256("WITHDRAW"); + + /// @notice Selects the Base fork at chain head — deliberately unpinned, + /// matching the live-drift-detector precedent in + /// `MigrateMultisigThresholdTest`. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + script = new GrantIssuerMintBurn(); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); + authoriser = LibAuthoriserInvariants.STOX_PROD_AUTHORISER; + } + + /// @notice `grantDeposit()` dry-run completes against the live pre-state, + /// writes the artifact with the pinned `meta.name` and exactly one tx, + /// and ends with the issuer NOT holding the role — the observable side + /// effect of the n+1 reversibility check having revoked the grant it + /// simulated. + function testGrantDepositCompletesAndWritesArtifact() external { + selectBaseFork(); + script.grantDeposit(); + + string memory json = vm.readFile(string.concat(vm.projectRoot(), "/out/grant-issuer-deposit.json")); + assertEq( + vm.parseJsonString(json, ".meta.name"), + "ST0x authoriser - grant DEPOSIT (mint) to new issuer", + "meta.name pinned" + ); + assertTrue(vm.keyExistsJson(json, ".transactions[0].to"), "first transaction present"); + assertFalse(vm.keyExistsJson(json, ".transactions[1].to"), "exactly one transaction emitted"); + + assertFalse( + IAccessControl(authoriser).hasRole(DEPOSIT, ISSUER), "n+1 reversibility revoked the simulated grant" + ); + } + + /// @notice `grantWithdraw()` dry-run completes, writes the burn-grant + /// artifact, and likewise ends with the role revoked. + function testGrantWithdrawCompletesAndWritesArtifact() external { + selectBaseFork(); + script.grantWithdraw(); + + string memory json = vm.readFile(string.concat(vm.projectRoot(), "/out/grant-issuer-withdraw.json")); + assertEq( + vm.parseJsonString(json, ".meta.name"), + "ST0x authoriser - grant WITHDRAW (burn) to new issuer", + "meta.name pinned" + ); + assertTrue(vm.keyExistsJson(json, ".transactions[0].to"), "first transaction present"); + assertFalse(vm.keyExistsJson(json, ".transactions[1].to"), "exactly one transaction emitted"); + + assertFalse( + IAccessControl(authoriser).hasRole(WITHDRAW, ISSUER), "n+1 reversibility revoked the simulated grant" + ); + } + + /// @notice `verifyDeposit()` accepts the artifact `grantDeposit()` emits. + /// The load-bearing round-trip: snapshot, author, roll the fork back to + /// the pre-run state, then verify against the artifact. + function testVerifyAcceptsDepositArtifact() external { + selectBaseFork(); + uint256 snapshot = vm.snapshotState(); + script.grantDeposit(); + vm.revertToState(snapshot); + script.verifyDeposit(string.concat(vm.projectRoot(), "/out/grant-issuer-deposit.json")); + } + + /// @notice `verifyWithdraw()` accepts the artifact `grantWithdraw()` + /// emits. + function testVerifyAcceptsWithdrawArtifact() external { + selectBaseFork(); + uint256 snapshot = vm.snapshotState(); + script.grantWithdraw(); + vm.revertToState(snapshot); + script.verifyWithdraw(string.concat(vm.projectRoot(), "/out/grant-issuer-withdraw.json")); + } + + /// @notice Inverted: the idempotency guard rejects authoring a no-op. If + /// the issuer already holds the role, the bundle would waste a signing + /// ceremony, so `grantDeposit()` must abort. Mocks only the + /// `hasRole(DEPOSIT, ISSUER)` pair so the pre-flight's other grant checks + /// still read live truth. + function testGrantDepositRejectsAlreadyGranted() external { + selectBaseFork(); + vm.mockCall( + authoriser, abi.encodeWithSelector(IAccessControl.hasRole.selector, DEPOSIT, ISSUER), abi.encode(true) + ); + vm.expectRevert(abi.encodeWithSelector(IssuerAlreadyHoldsRole.selector, DEPOSIT)); + script.grantDeposit(); + } + + /// @notice Inverted: the pre-flight rejects authoriser grant drift. If a + /// pinned `(role, grantee)` pair is missing — here the Safe's + /// `DEPOSIT_ADMIN`, which is also what authorises the Safe to grant + /// `DEPOSIT` at all — the bundle must abort before producing an artifact. + function testGrantRejectsAuthoriserGrantDrift() external { + selectBaseFork(); + bytes32 depositAdmin = keccak256("DEPOSIT_ADMIN"); + address safeGrantee = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; + vm.mockCall( + authoriser, + abi.encodeWithSelector(IAccessControl.hasRole.selector, depositAdmin, safeGrantee), + abi.encode(false) + ); + vm.expectRevert(abi.encodeWithSelector(ExpectedGrantMissing.selector, authoriser, depositAdmin, safeGrantee)); + script.grantDeposit(); + } + + /// @notice Inverted: the pre-flight rejects vault-ownership drift. If even + /// one production receipt vault's `owner()` points off the Safe, the + /// grant must abort (the authoriser would be gating a vault the Safe no + /// longer controls). + function testGrantRejectsVaultOwnershipDrift() external { + selectBaseFork(); + address rogueOwner = address(0xBADC0DE); + address victim = LibTokenInvariants.MSTR_RECEIPT_VAULT; + vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); + vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, address(safe), rogueOwner)); + script.grantDeposit(); + } + + /// @notice Inverted: the pre-flight rejects vault-authoriser drift. This + /// is the load-bearing coverage property — if even one vault's + /// `authorizer()` diverges from the target authoriser, the global grant + /// would NOT reach that SFT, so the bundle must abort. + function testGrantRejectsVaultAuthoriserDrift() external { + selectBaseFork(); + address rogueAuthoriser = address(0xBADC0DE); + address victim = LibTokenInvariants.MSTR_RECEIPT_VAULT; + vm.mockCall(victim, abi.encodeWithSelector(IAuthorisable.authorizer.selector), abi.encode(rogueAuthoriser)); + vm.expectRevert( + abi.encodeWithSelector(ReceiptVaultAuthoriserMismatch.selector, victim, authoriser, rogueAuthoriser) + ); + script.grantDeposit(); + } + + /// @notice Inverted: `verifyDeposit()` rejects an artifact with a wrong + /// `chainId`. + function testVerifyRejectsWrongChainId() external { + selectBaseFork(); + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (DEPOSIT, ISSUER)), + operation: 0 + }); + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid + 1, "grant-wrong-chain", txs); + string memory path = string.concat(vm.projectRoot(), "/out/grant-wrong-chain.json"); + vm.writeFile(path, json); + + vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "chainId")); + script.verifyDeposit(path); + } + + /// @notice Inverted: `verifyDeposit()` rejects an artifact that encodes a + /// different role (here a WITHDRAW grant verified as a DEPOSIT grant). + /// The calldata differs, so the `data` field check trips. + function testVerifyRejectsWrongRoleData() external { + selectBaseFork(); + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({ + to: authoriser, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (WITHDRAW, ISSUER)), + operation: 0 + }); + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, "grant-wrong-role", txs); + string memory path = string.concat(vm.projectRoot(), "/out/grant-wrong-role.json"); + vm.writeFile(path, json); + + vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "data")); + script.verifyDeposit(path); + } + + /// @notice Inverted: `verifyDeposit()` rejects an artifact whose tx + /// targets a different contract than the authoriser. + function testVerifyRejectsWrongTarget() external { + selectBaseFork(); + address impostor = address(0xCAFEBABE); + SafeTx[] memory txs = new SafeTx[](1); + txs[0] = SafeTx({ + to: impostor, + value: 0, + data: abi.encodeCall(IAccessControl.grantRole, (DEPOSIT, ISSUER)), + operation: 0 + }); + string memory json = LibSafeOps.emitTxBuilderJson(address(safe), block.chainid, "grant-wrong-target", txs); + string memory path = string.concat(vm.projectRoot(), "/out/grant-wrong-target.json"); + vm.writeFile(path, json); + + vm.expectRevert(abi.encodeWithSelector(VerifyMismatch.selector, "to")); + script.verifyDeposit(path); + } +}