Skip to content
Merged
1 change: 1 addition & 0 deletions .github/workflows/run-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ on:
- 20260722-swap-remaining-vault-authorisers
- 20260723-provision-additional-service-signer
- 20260729-migrate-governance-to-timelock
- 20260810-revoke-fireblocks-service-signer
network:
description: 'Network to author against (default: base)'
required: true
Expand Down
26 changes: 5 additions & 21 deletions script/20260729-migrate-governance-to-timelock.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,6 @@ struct MigrationTargets {
bytes32[] renounceRoles;
}

/// @notice A field of the artifact under signer-side verification does not
/// match the bundle re-derived from CURRENT live chain state. The artifact
/// is stale (state moved since authoring), tampered, or authored for a
/// different chain — either way it must not be signed.
/// @param field The first mismatching field.
error MigrationVerifyMismatch(string field);

/// @notice The end-to-end governance-loop proof did not leave the scheduled
/// operation in the `Done` state — the schedule → delay → execute path a
/// future admin action must take does not work against the post-migration
Expand Down Expand Up @@ -384,24 +377,15 @@ contract MigrateGovernanceToTimelock is Script {
}

/// @notice Compare a parsed artifact against the live-derived bundle
/// field by field, then log the canonical MultiSend `SafeTxHash` at the
/// live nonce for the Safe-UI cross-check. Split from `verify` for the
/// same legacy-codegen stack-limit reason as `_assertPostState`.
/// via the shared `LibSafeOps.assertParsedTxsMatch`, then log the
/// canonical MultiSend `SafeTxHash` at the live nonce for the Safe-UI
/// cross-check. Split from `verify` for the same legacy-codegen
/// stack-limit reason as `_assertPostState`.
/// @param safe The chain's token-owner Safe.
/// @param expected The bundle derived from live state.
/// @param jsonPath Filesystem path to the artifact under verification.
function _verifyArtifact(IGnosisSafe safe, SafeTx[] memory expected, string calldata jsonPath) internal view {
(uint256 parsedChainId, address parsedFirstTarget, SafeTx[] memory parsed) =
LibSafeOps.parseTxBuilderJson(jsonPath);
if (parsedChainId != block.chainid) revert MigrationVerifyMismatch("chainId");
if (parsed.length != expected.length) revert MigrationVerifyMismatch("txCount");
if (parsedFirstTarget != expected[0].to) revert MigrationVerifyMismatch("firstTarget");
for (uint256 i = 0; i < expected.length; i++) {
if (parsed[i].to != expected[i].to) revert MigrationVerifyMismatch("to");
if (parsed[i].value != expected[i].value) revert MigrationVerifyMismatch("value");
if (parsed[i].operation != expected[i].operation) revert MigrationVerifyMismatch("operation");
if (keccak256(parsed[i].data) != keccak256(expected[i].data)) revert MigrationVerifyMismatch("data");
}
LibSafeOps.assertParsedTxsMatch(expected, jsonPath);

uint256 nonce = safe.nonce();
console2.log("Artifact verified against live state.");
Expand Down
376 changes: 376 additions & 0 deletions script/20260810-revoke-fireblocks-service-signer.s.sol

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions src/lib/LibAuthoriserInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,8 @@ library LibAuthoriserInvariants {
/// constant for call-site clarity.
address internal constant GRANTEE_TOKEN_OWNER_SAFE = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE;

/// @notice External service EOA granted `DEPOSIT` (block 41797262),
/// `WITHDRAW` (block 41797281) and `CERTIFY` (block 41797297) shortly
/// after the first service was provisioned. EOA, active service signer.
/// @dev TODO: confirm identity and rename.
/// @notice The original service EOA — Fireblocks-custodied — holding
/// `DEPOSIT`, `WITHDRAW` and `CERTIFY` on each chain's authoriser.
/// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9
address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9;

Expand Down
35 changes: 35 additions & 0 deletions src/lib/LibSafeOps.sol
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ error TxBuilderJsonNoTransactions();
/// @param operation The unsupported operation value.
error TxBuilderJsonUnsupportedOperation(uint256 index, uint8 operation);

/// @notice A field of a Tx Builder artifact under signer-side verification
/// does not match the bundle re-derived from CURRENT live chain state. The
/// artifact is stale (state moved since authoring), tampered, or authored
/// for a different chain — either way it must not be signed.
/// @param field The first mismatching field.
error TxBuilderArtifactMismatch(string field);

/// @notice A single Safe-Tx Builder transaction in canonical form. Mirrors
/// the per-transaction shape of the Safe Tx Builder JSON.
/// @param to The destination address of the inner transaction.
Expand Down Expand Up @@ -340,6 +347,34 @@ library LibSafeOps {
safeAddr = txs[0].to;
}

/// @notice Signer-side integrity comparison shared by every script's
/// `verify(string)`: parse the Tx Builder artifact at `jsonPath` and
/// assert it matches `expected` — the bundle the caller re-derived from
/// CURRENT live chain state — byte-exactly. Checks the artifact's chain
/// id against the active chain, the transaction count, the first
/// target, and every transaction's `to` / `value` / `operation` /
/// calldata. Reverts `TxBuilderArtifactMismatch` naming the first
/// mismatching field; returns silently on an exact match.
/// @dev One shared comparison rather than one per script: two copies
/// had already drifted (one skipped `operation`) before this was
/// extracted, and the comparison is exactly the part of a `verify`
/// that must not vary per script — only the bundle re-derivation is
/// script-specific.
/// @param expected The bundle derived from live state.
/// @param jsonPath Filesystem path to the artifact under verification.
function assertParsedTxsMatch(SafeTx[] memory expected, string memory jsonPath) internal view {
(uint256 parsedChainId, address parsedFirstTarget, SafeTx[] memory parsed) = parseTxBuilderJson(jsonPath);
if (parsedChainId != block.chainid) revert TxBuilderArtifactMismatch("chainId");
if (parsed.length != expected.length) revert TxBuilderArtifactMismatch("txCount");
if (parsedFirstTarget != expected[0].to) revert TxBuilderArtifactMismatch("firstTarget");
for (uint256 i = 0; i < expected.length; i++) {
if (parsed[i].to != expected[i].to) revert TxBuilderArtifactMismatch("to");
if (parsed[i].value != expected[i].value) revert TxBuilderArtifactMismatch("value");
if (parsed[i].operation != expected[i].operation) revert TxBuilderArtifactMismatch("operation");
if (keccak256(parsed[i].data) != keccak256(expected[i].data)) revert TxBuilderArtifactMismatch("data");
}
}

/// @notice Parse a decimal-formatted unsigned integer string. The Tx
/// Builder schema serialises `chainId` and `value` as decimal strings,
/// so this is the inverse of `vm.toString(uint256)`.
Expand Down
7 changes: 3 additions & 4 deletions test/script/20260729-migrate-governance-to-timelock.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import {
TimelockNotPinned,
UnexpectedVaultOwner,
UnexpectedBeaconOwner,
NothingToMigrate,
MigrationVerifyMismatch
NothingToMigrate
} from "../../script/20260729-migrate-governance-to-timelock.s.sol";
import {MigrateGovernanceToTimelockHarness} from "./MigrateGovernanceToTimelockHarness.sol";
import {LibAuthoriserInvariants, RoleGrant} from "../../src/lib/LibAuthoriserInvariants.sol";
Expand All @@ -30,7 +29,7 @@ import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol";

import {IAuthorisable} from "../../src/interface/IAuthorisable.sol";
import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol";
import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol";
import {LibSafeOps, SafeTx, TxBuilderArtifactMismatch} from "../../src/lib/LibSafeOps.sol";
import {LibTimelockInvariants} from "../../src/lib/LibTimelockInvariants.sol";
import {LibTokenInvariants, TokenInstance} from "../../src/lib/LibTokenInvariants.sol";

Expand Down Expand Up @@ -490,7 +489,7 @@ contract MigrateGovernanceToTimelockTest is Test {
"out/tampered-migration.json",
LibSafeOps.emitTxBuilderJson(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE, block.chainid, "tampered", txs)
);
vm.expectRevert(abi.encodeWithSelector(MigrationVerifyMismatch.selector, "data"));
vm.expectRevert(abi.encodeWithSelector(TxBuilderArtifactMismatch.selector, "data"));
verifier.verify("out/tampered-migration.json");
}

Expand Down
Loading
Loading