From b53a3277ba12c973a9704ca19987bcec5fc33184 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Fri, 29 May 2026 14:03:20 +0000 Subject: [PATCH 01/11] test(failing): pin uniform authoriser across prod receipt vaults Adds LibSafeInvariants.assertUniformAuthoriser + a fork test pinning that every production receipt vault shares PROD_RECEIPT_VAULT_AUTHORISER. Committed red on purpose as a forcing function: 12 of 13 vaults share the authoriser, IBHG still diverges (0x6e0F1c31...). The test reverts with ReceiptVaultAuthoriserMismatch naming IBHG until its authoriser is migrated on-chain, then greens with no code change. Standalone prod-state invariant, deliberately NOT part of the assertAll Safe pre-flight bundle so it never gates operational scripts. --- src/lib/LibProdTokensBase.sol | 9 +++++ src/lib/LibSafeInvariants.sol | 39 +++++++++++++++++++ .../src/lib/LibProdAuthoriserUniformity.t.sol | 38 ++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 test/src/lib/LibProdAuthoriserUniformity.t.sol diff --git a/src/lib/LibProdTokensBase.sol b/src/lib/LibProdTokensBase.sol index 17f14189..7d2f96f2 100644 --- a/src/lib/LibProdTokensBase.sol +++ b/src/lib/LibProdTokensBase.sol @@ -217,6 +217,15 @@ library LibProdTokensBase { /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 address constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); + /// @notice The authoriser that every production receipt vault is expected + /// to share. This is the value 12 of the 13 vaults report today. + /// @dev As of 2026-05-29 one vault (IBHG) still reports a different + /// authoriser (`0x6e0F1c31Fca4Ff07cD0C3e8658b1e3a473f3393a`); the + /// uniform-authoriser invariant test pinning this constant therefore + /// fails on IBHG by design, as a forcing function to bring it into line. + /// Read from `authorizer()` on the live vaults on Base on 2026-05-29. + address constant PROD_RECEIPT_VAULT_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); + /// @notice Returns the 13 production receipt vault addresses on Base, in /// the order they were deployed. Provided so consumers (e.g. invariant /// assertions, migration scripts) can iterate without hardcoding the diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index 27770d7f..9071882c 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -17,6 +17,16 @@ interface IOwnable { function owner() external view returns (address); } +/// @notice Minimal authoriser-getter surface exposed by ST0x receipt +/// vaults. Declared inline (returning `address`) rather than importing +/// the upstream `IAuthorizableV1` so this library owns its only external +/// surface and doesn't carry the upstream's richer return type. +interface IAuthorisable { + /// @notice The authoriser contract gating restricted vault operations. + /// @return The authoriser address. + function authorizer() external view returns (address); +} + /// @notice The runtime codehash at the Safe's address does not match the /// pinned Safe v1.4.1 L2 proxy codehash. Signals either that the address has /// been swapped under us or that the Safe singleton has been redeployed with @@ -96,6 +106,14 @@ error SafeFallbackHandlerMismatch(address safe, address expected, address actual /// @param actual The owner address returned by `vault.owner()`. error ReceiptVaultOwnerMismatch(address vault, address expected, address actual); +/// @notice A production receipt vault's `authorizer()` does not match the +/// authoriser every vault is expected to share. Surfaces the exact vault +/// that breaks the uniform-authoriser invariant. +/// @param vault The receipt vault whose authoriser was read. +/// @param expected The authoriser address every vault is expected to share. +/// @param actual The authoriser address returned by `vault.authorizer()`. +error ReceiptVaultAuthoriserMismatch(address vault, address expected, address actual); + /// @notice The Safe's `getOwners()` array length does not match the /// caller-supplied `expected` array length. /// @param safe The Safe address whose owner set was queried. @@ -381,4 +399,25 @@ library LibSafeInvariants { function assertAll(IGnosisSafe safe) internal view { assertAll(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, LibProdSafes.expectedOwners()); } + + /// @notice Every production receipt vault reports the same authoriser. + /// Iterates `LibProdTokensBase.productionReceiptVaults` and reverts with + /// `ReceiptVaultAuthoriserMismatch` on the first vault whose + /// `authorizer()` diverges from `expected`, surfacing the offending vault. + /// @dev A divergent authoriser means a token is gated by a different RBAC + /// contract than the rest of the system — the class of inconsistency a + /// uniform authoriser is meant to prevent. This is deliberately NOT part + /// of the `assertAll` bundle: it is a standalone prod-state invariant, not + /// a Safe pre-flight check, and it must not gate the operational scripts. + /// @param expected The authoriser address every production receipt vault + /// is expected to share. + function assertUniformAuthoriser(address expected) internal view { + address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); + for (uint256 i = 0; i < vaults.length; i++) { + address actual = IAuthorisable(vaults[i]).authorizer(); + if (actual != expected) { + revert ReceiptVaultAuthoriserMismatch(vaults[i], expected, actual); + } + } + } } diff --git a/test/src/lib/LibProdAuthoriserUniformity.t.sol b/test/src/lib/LibProdAuthoriserUniformity.t.sol new file mode 100644 index 00000000..3a1e7592 --- /dev/null +++ b/test/src/lib/LibProdAuthoriserUniformity.t.sol @@ -0,0 +1,38 @@ +// 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 {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibProdAuthoriserUniformityTest +/// @notice Pins that every production receipt vault on Base shares the same +/// authoriser. +/// +/// This test is EXPECTED TO FAIL today and is committed red on purpose, as a +/// forcing function — the same pattern used elsewhere for prod-state drift +/// that needs an owner to resolve it. As of 2026-05-29, 12 of the 13 vaults +/// report `PROD_RECEIPT_VAULT_AUTHORISER`; IBHG still reports a different +/// authoriser (`0x6e0F1c31Fca4Ff07cD0C3e8658b1e3a473f3393a`). The assertion +/// reverts with `ReceiptVaultAuthoriserMismatch` naming IBHG until its +/// authoriser is brought into line on-chain, at which point this test greens +/// automatically with no code change. +contract LibProdAuthoriserUniformityTest is Test { + /// @notice Selects the Base fork at chain head — deliberately unpinned, so + /// the next CI run is the canary for the IBHG authoriser being fixed (or + /// any new vault diverging). Matches the unpinned head-fork convention + /// used by the other prod-state drift detectors in this repo. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + } + + /// @notice Every production receipt vault reports + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. Fails until the + /// last divergent vault (IBHG) is migrated to the shared authoriser. + function testProdReceiptVaultsShareUniformAuthoriser() external { + selectBaseFork(); + LibSafeInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); + } +} From dd3ca93af6482b80735c3028a5114001c5d60562 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Fri, 29 May 2026 14:15:56 +0000 Subject: [PATCH 02/11] feat(safe): fold uniform-authoriser invariant into assertAll assertImmutableInvariants (and therefore assertAll) now asserts every production receipt vault shares PROD_RECEIPT_VAULT_AUTHORISER, alongside the existing uniform-ownership leg. Treats authoriser uniformity as a first-class token-side invariant rather than a standalone forcing-function test. Transiently red until the last divergent vault (IBHG) is migrated to the shared authoriser on-chain; greens automatically thereafter with no code change. --- src/lib/LibProdTokensBase.sol | 13 ++++---- src/lib/LibSafeInvariants.sol | 14 ++++++--- .../src/lib/LibProdAuthoriserUniformity.t.sol | 30 +++++++++---------- 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/lib/LibProdTokensBase.sol b/src/lib/LibProdTokensBase.sol index 7d2f96f2..024aef72 100644 --- a/src/lib/LibProdTokensBase.sol +++ b/src/lib/LibProdTokensBase.sol @@ -217,13 +217,12 @@ library LibProdTokensBase { /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 address constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); - /// @notice The authoriser that every production receipt vault is expected - /// to share. This is the value 12 of the 13 vaults report today. - /// @dev As of 2026-05-29 one vault (IBHG) still reports a different - /// authoriser (`0x6e0F1c31Fca4Ff07cD0C3e8658b1e3a473f3393a`); the - /// uniform-authoriser invariant test pinning this constant therefore - /// fails on IBHG by design, as a forcing function to bring it into line. - /// Read from `authorizer()` on the live vaults on Base on 2026-05-29. + /// @notice The single authoriser every production receipt vault is gated + /// by. Pinned as a first-class invariant (see + /// `LibSafeInvariants.assertUniformAuthoriser`, folded into `assertAll`). + /// @dev Read from `authorizer()` on the live vaults on Base on 2026-05-29. + /// A vault reporting any other authoriser is gated by a different RBAC + /// contract than the rest of the system and trips the invariant. address constant PROD_RECEIPT_VAULT_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); /// @notice Returns the 13 production receipt vault addresses on Base, in diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index 9071882c..e6491b19 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -313,6 +313,12 @@ library LibSafeInvariants { revert ReceiptVaultOwnerMismatch(vaults[i], safeAddr, actualOwner); } } + + // Token-side uniform authoriser: every production receipt vault is + // gated by the same authoriser contract. A divergent authoriser means + // a token follows a different RBAC contract than the rest of the + // system — the inconsistency this invariant exists to prevent. + assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); } /// @notice Reads a single 32-byte storage slot from a Safe via @@ -405,10 +411,10 @@ library LibSafeInvariants { /// `ReceiptVaultAuthoriserMismatch` on the first vault whose /// `authorizer()` diverges from `expected`, surfacing the offending vault. /// @dev A divergent authoriser means a token is gated by a different RBAC - /// contract than the rest of the system — the class of inconsistency a - /// uniform authoriser is meant to prevent. This is deliberately NOT part - /// of the `assertAll` bundle: it is a standalone prod-state invariant, not - /// a Safe pre-flight check, and it must not gate the operational scripts. + /// contract than the rest of the system — the class of inconsistency this + /// invariant exists to prevent. Folded into `assertImmutableInvariants` + /// (and therefore `assertAll`) as a first-class token-side invariant + /// alongside uniform ownership; also callable standalone. /// @param expected The authoriser address every production receipt vault /// is expected to share. function assertUniformAuthoriser(address expected) internal view { diff --git a/test/src/lib/LibProdAuthoriserUniformity.t.sol b/test/src/lib/LibProdAuthoriserUniformity.t.sol index 3a1e7592..4e6cf4e8 100644 --- a/test/src/lib/LibProdAuthoriserUniformity.t.sol +++ b/test/src/lib/LibProdAuthoriserUniformity.t.sol @@ -8,29 +8,29 @@ import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// @title LibProdAuthoriserUniformityTest -/// @notice Pins that every production receipt vault on Base shares the same -/// authoriser. +/// @notice Focused pin on the uniform-authoriser invariant: every production +/// receipt vault on Base shares `PROD_RECEIPT_VAULT_AUTHORISER`. /// -/// This test is EXPECTED TO FAIL today and is committed red on purpose, as a -/// forcing function — the same pattern used elsewhere for prod-state drift -/// that needs an owner to resolve it. As of 2026-05-29, 12 of the 13 vaults -/// report `PROD_RECEIPT_VAULT_AUTHORISER`; IBHG still reports a different -/// authoriser (`0x6e0F1c31Fca4Ff07cD0C3e8658b1e3a473f3393a`). The assertion -/// reverts with `ReceiptVaultAuthoriserMismatch` naming IBHG until its -/// authoriser is brought into line on-chain, at which point this test greens -/// automatically with no code change. +/// This is a first-class token-side invariant — it is also folded into +/// `assertImmutableInvariants` (and therefore `assertAll`), so the production +/// Safe invariant suite carries it too. This file exists as a named, +/// standalone signal for this specific drift surface. +/// +/// It may be transiently red: at the time of writing one vault (IBHG) is +/// being migrated onto the shared authoriser. Once that lands on-chain this +/// greens automatically with no code change. The unpinned head fork means the +/// next CI run is the canary for the migration completing. contract LibProdAuthoriserUniformityTest is Test { /// @notice Selects the Base fork at chain head — deliberately unpinned, so - /// the next CI run is the canary for the IBHG authoriser being fixed (or - /// any new vault diverging). Matches the unpinned head-fork convention - /// used by the other prod-state drift detectors in this repo. + /// the next CI run reflects the current on-chain authoriser wiring. Matches + /// the unpinned head-fork convention used by the other prod-state drift + /// detectors in this repo. function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); } /// @notice Every production receipt vault reports - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. Fails until the - /// last divergent vault (IBHG) is migrated to the shared authoriser. + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. function testProdReceiptVaultsShareUniformAuthoriser() external { selectBaseFork(); LibSafeInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); From 2dfe631d9e0a5aa6c6bda11345e20866efe52697 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Fri, 29 May 2026 16:22:54 +0000 Subject: [PATCH 03/11] refactor(safe): extract LibTokenInvariants from LibSafeInvariants A receipt vault's owner/authoriser uniformity is a token-side concern, not a Safe concern, so it does not belong in a Safe-named library. Move the IOwnable/IAuthorisable interfaces, the ReceiptVaultOwnerMismatch and ReceiptVaultAuthoriserMismatch errors, and the uniform-ownership and uniform-authoriser asserts out of LibSafeInvariants into a new LibTokenInvariants library. The uniform-ownership loop that was inline in assertImmutableInvariants is lifted into a named assertUniformOwnership(expectedOwner) function. assertImmutableInvariants is now pure Safe identity/config: codehash, singleton pointer + bytecode, version, modules, guard, and fallback handler. The token-side legs move out of it and are composed into assertAll instead, so the full production-state bundle still carries them while the immutable-Safe check stays Safe-only. Tests follow the code: the focused authoriser pin and a new uniform-ownership pin live in LibTokenInvariants.t.sol; the old LibProdAuthoriserUniformity.t.sol is dropped and the assertImmutableInvariants token-ownership inverted test is replaced by an assertUniformOwnership-targeted one. assertAll-based fork tests are unchanged and now exercise the composed token legs. Co-Authored-By: Claude Opus 4.7 --- src/lib/LibProdTokensBase.sol | 3 +- src/lib/LibSafeInvariants.sol | 154 +++++------------- src/lib/LibTokenInvariants.sol | 102 ++++++++++++ .../script/MigrateMultisigThresholdTest.t.sol | 8 +- .../src/lib/LibProdAuthoriserUniformity.t.sol | 38 ----- test/src/lib/LibSafeInvariants.t.sol | 17 -- test/src/lib/LibTokenInvariants.t.sol | 71 ++++++++ test/src/lib/LibTokenInvariantsHarness.sol | 20 +++ 8 files changed, 240 insertions(+), 173 deletions(-) create mode 100644 src/lib/LibTokenInvariants.sol delete mode 100644 test/src/lib/LibProdAuthoriserUniformity.t.sol create mode 100644 test/src/lib/LibTokenInvariants.t.sol create mode 100644 test/src/lib/LibTokenInvariantsHarness.sol diff --git a/src/lib/LibProdTokensBase.sol b/src/lib/LibProdTokensBase.sol index 024aef72..a0ce9cbf 100644 --- a/src/lib/LibProdTokensBase.sol +++ b/src/lib/LibProdTokensBase.sol @@ -219,7 +219,8 @@ library LibProdTokensBase { /// @notice The single authoriser every production receipt vault is gated /// by. Pinned as a first-class invariant (see - /// `LibSafeInvariants.assertUniformAuthoriser`, folded into `assertAll`). + /// `LibTokenInvariants.assertUniformAuthoriser`, composed into + /// `LibSafeInvariants.assertAll`). /// @dev Read from `authorizer()` on the live vaults on Base on 2026-05-29. /// A vault reporting any other authoriser is gated by a different RBAC /// contract than the rest of the system and trips the invariant. diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index e6491b19..c33b32c9 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -5,27 +5,7 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; import {LibProdSafes} from "./LibProdSafes.sol"; import {LibProdTokensBase} from "./LibProdTokensBase.sol"; - -/// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. -/// Every production receipt vault exposes `owner()`; this library only -/// needs the getter, not the transfer/renounce mutators. Declared inline -/// here so the Safe invariant bundle owns its only external surface -/// rather than depending on a token-side interface that could drift. -interface IOwnable { - /// @notice The current owner of the contract. - /// @return The owner address. - function owner() external view returns (address); -} - -/// @notice Minimal authoriser-getter surface exposed by ST0x receipt -/// vaults. Declared inline (returning `address`) rather than importing -/// the upstream `IAuthorizableV1` so this library owns its only external -/// surface and doesn't carry the upstream's richer return type. -interface IAuthorisable { - /// @notice The authoriser contract gating restricted vault operations. - /// @return The authoriser address. - function authorizer() external view returns (address); -} +import {LibTokenInvariants} from "./LibTokenInvariants.sol"; /// @notice The runtime codehash at the Safe's address does not match the /// pinned Safe v1.4.1 L2 proxy codehash. Signals either that the address has @@ -96,24 +76,6 @@ error SafeUnexpectedGuard(address safe, address guard); /// fallback handler slot. error SafeFallbackHandlerMismatch(address safe, address expected, address actual); -/// @notice A production receipt vault's `owner()` does not match the Safe -/// the immutable-invariants leg expected to own every vault. Surfaces the -/// exact vault address that breaks the uniform-ownership invariant rather -/// than a generic mismatch. -/// @param vault The receipt vault whose owner was read. -/// @param expected The Safe address every vault is expected to report as -/// `owner()`. -/// @param actual The owner address returned by `vault.owner()`. -error ReceiptVaultOwnerMismatch(address vault, address expected, address actual); - -/// @notice A production receipt vault's `authorizer()` does not match the -/// authoriser every vault is expected to share. Surfaces the exact vault -/// that breaks the uniform-authoriser invariant. -/// @param vault The receipt vault whose authoriser was read. -/// @param expected The authoriser address every vault is expected to share. -/// @param actual The authoriser address returned by `vault.authorizer()`. -error ReceiptVaultAuthoriserMismatch(address vault, address expected, address actual); - /// @notice The Safe's `getOwners()` array length does not match the /// caller-supplied `expected` array length. /// @param safe The Safe address whose owner set was queried. @@ -145,13 +107,12 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// or reverts with a typed error that pinpoints the drift. /// @dev The library splits checks into two categories: /// -/// - **Immutable invariants** (`assertImmutableInvariants`) — properties -/// that always hold against this Safe regardless of any pending or past -/// migration: proxy codehash, singleton pointer + bytecode, version, -/// modules empty, guard zero, fallback handler pinned, and uniform -/// `owner()` across every production receipt vault. The same set is -/// evaluated pre-migration and post-migration; nothing here is -/// parameterised on operational intent. +/// - **Immutable invariants** (`assertImmutableInvariants`) — pure Safe +/// identity and configuration properties that always hold against this +/// Safe regardless of any pending or past migration: proxy codehash, +/// singleton pointer + bytecode, version, modules empty, guard zero, and +/// fallback handler pinned. The same set is evaluated pre-migration and +/// post-migration; nothing here is parameterised on operational intent. /// /// - **Parameterised state assertions** (`assertOwnerSet`, `assertThreshold`) /// — properties whose expected value is supplied by the caller because @@ -159,8 +120,10 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// threshold migration). Wrong values here are caller intent, not Safe /// drift, so the comparison target is an argument. /// -/// The `assertAll` overloads bundle the immutable invariants and the two -/// parameterised checks into a single call site. The pattern mirrors +/// The `assertAll` overloads bundle the Safe-side immutable invariants, +/// the two parameterised checks, and the token-side uniformity invariants +/// (`LibTokenInvariants.assertUniformOwnership` / +/// `assertUniformAuthoriser`) into a single call site. The pattern mirrors /// `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that takes /// every expected value, and a no-arg default that fills in the /// current-truth pins from `LibProdSafes`. Scripts default to the no-arg @@ -210,33 +173,26 @@ library LibSafeInvariants { /// @notice Assert every immutable invariant of the Safe at `safe`: /// pinned proxy codehash, pinned singleton pointer, pinned singleton - /// bytecode, pinned version, no modules, no guard, pinned fallback - /// handler, and uniform `owner()` across every production ST0x receipt - /// vault (as enumerated by - /// `LibProdTokensBase.productionReceiptVaults`). Reverts with a typed - /// error on first failure; returns silently otherwise. - /// @dev "Immutable" here means properties that should hold against the - /// production Safe at any point in time, regardless of pending or - /// past operational migrations. The same set is asserted pre-migration - /// and post-migration; nothing in this call is parameterised on - /// caller intent. - /// - /// Token-side uniform ownership is included as an immutable Safe - /// invariant because the threshold migration (and any future Safe - /// migration on this deployment) does not independently transfer - /// vault ownership: drift in the vault ownership set against the Safe - /// at migration time is therefore an invariant break to surface here, - /// not a separate pre-flight concern of every consumer. + /// bytecode, pinned version, no modules, no guard, and pinned fallback + /// handler. Reverts with a typed error on first failure; returns + /// silently otherwise. + /// @dev "Immutable" here means pure Safe identity and configuration + /// properties that should hold against the production Safe at any + /// point in time, regardless of pending or past operational + /// migrations. The same set is asserted pre-migration and + /// post-migration; nothing in this call is parameterised on caller + /// intent. Token-side uniformity (vault owner/authoriser) is a + /// separate concern composed into `assertAll` via `LibTokenInvariants` + /// rather than here, because it is a property of the token deployment + /// rather than of the Safe. /// /// The check ordering is deliberate. Codehash first (cheapest, and /// catches an EOA at the address or a fake proxy). Singleton slot next /// (catches a swap of the implementation pointer). Singleton bytecode /// third (catches a swap behind the singleton address). VERSION() /// fourth (catches an unexpected implementation that happens to have - /// the same bytecode hash). Modules/guard/fallback handler next, after - /// the proxy has been proven to be the singleton we expect. Uniform - /// vault ownership last, because it is the most expensive (13 external - /// calls) and only meaningful once the Safe itself has been validated. + /// the same bytecode hash). Modules/guard/fallback handler last, after + /// the proxy has been proven to be the singleton we expect. /// @param safe The Safe to assert immutable invariants on. function assertImmutableInvariants(IGnosisSafe safe) internal view { address safeAddr = address(safe); @@ -300,25 +256,6 @@ library LibSafeInvariants { safeAddr, LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, actualFallbackHandler ); } - - // Token-side uniform ownership: every production receipt vault - // reports `owner() == safe`. Iterates the vault list emitted by - // `LibProdTokensBase.productionReceiptVaults` and reverts with - // `ReceiptVaultOwnerMismatch` on the first drift, surfacing the - // offending vault. - address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); - for (uint256 i = 0; i < vaults.length; i++) { - address actualOwner = IOwnable(vaults[i]).owner(); - if (actualOwner != safeAddr) { - revert ReceiptVaultOwnerMismatch(vaults[i], safeAddr, actualOwner); - } - } - - // Token-side uniform authoriser: every production receipt vault is - // gated by the same authoriser contract. A divergent authoriser means - // a token follows a different RBAC contract than the rest of the - // system — the inconsistency this invariant exists to prevent. - assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); } /// @notice Reads a single 32-byte storage slot from a Safe via @@ -371,7 +308,17 @@ library LibSafeInvariants { /// the expected threshold or owner set from the `LibProdSafes` /// current-truth pins — typically only when running a script that /// intentionally changes one of those (post-state assertion). - /// @dev Mirrors the `StoxProdV2Test::checkAllV2OnChain` pattern: a + /// @dev Composes both the Safe-side invariants (immutable Safe + /// identity/config, owner set, threshold) and the token-side + /// uniformity invariants (`LibTokenInvariants.assertUniformOwnership` + /// against the Safe, and `assertUniformAuthoriser` against the pinned + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`). The token legs + /// live in `LibTokenInvariants` because vault owner/authoriser + /// uniformity is a property of the token deployment, but they are + /// bundled here so a consumer asserting the full production state never + /// has to remember to run them separately. + /// + /// Mirrors the `StoxProdV2Test::checkAllV2OnChain` pattern: a /// full-args helper alongside a no-arg overload. Migration scripts /// call the no-arg overload pre-execution to assert the pinned /// current truth, then call this overload post-execution with the @@ -381,7 +328,11 @@ library LibSafeInvariants { /// separate body: keeping each underlying check addressable in /// isolation lets fork tests exercise individual drift surfaces, and /// keeping the bundle alongside them means migration code never has - /// to remember which of the three pieces to run. + /// to remember which of the pieces to run. The token-side ownership + /// leg is asserted against `address(safe)`, so it also surfaces vault + /// ownership drift against the Safe at migration time. Both token legs + /// run last because they are the most expensive (13 external calls + /// each) and only meaningful once the Safe itself has been validated. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. @@ -389,6 +340,8 @@ library LibSafeInvariants { assertImmutableInvariants(safe); assertOwnerSet(safe, expectedOwners); assertThreshold(safe, expectedThreshold); + LibTokenInvariants.assertUniformOwnership(address(safe)); + LibTokenInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); } /// @notice No-arg invariant bundle that fills in the @@ -405,25 +358,4 @@ library LibSafeInvariants { function assertAll(IGnosisSafe safe) internal view { assertAll(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, LibProdSafes.expectedOwners()); } - - /// @notice Every production receipt vault reports the same authoriser. - /// Iterates `LibProdTokensBase.productionReceiptVaults` and reverts with - /// `ReceiptVaultAuthoriserMismatch` on the first vault whose - /// `authorizer()` diverges from `expected`, surfacing the offending vault. - /// @dev A divergent authoriser means a token is gated by a different RBAC - /// contract than the rest of the system — the class of inconsistency this - /// invariant exists to prevent. Folded into `assertImmutableInvariants` - /// (and therefore `assertAll`) as a first-class token-side invariant - /// alongside uniform ownership; also callable standalone. - /// @param expected The authoriser address every production receipt vault - /// is expected to share. - function assertUniformAuthoriser(address expected) internal view { - address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); - for (uint256 i = 0; i < vaults.length; i++) { - address actual = IAuthorisable(vaults[i]).authorizer(); - if (actual != expected) { - revert ReceiptVaultAuthoriserMismatch(vaults[i], expected, actual); - } - } - } } diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol new file mode 100644 index 00000000..f12ee1f1 --- /dev/null +++ b/src/lib/LibTokenInvariants.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {LibProdTokensBase} from "./LibProdTokensBase.sol"; + +/// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. +/// Every production receipt vault exposes `owner()`; this library only +/// needs the getter, not the transfer/renounce mutators. Declared inline +/// here so the token-invariant bundle owns its only external surface +/// rather than depending on a richer token-side interface that could drift. +interface IOwnable { + /// @notice The current owner of the contract. + /// @return The owner address. + function owner() external view returns (address); +} + +/// @notice Minimal authoriser-getter surface exposed by ST0x receipt +/// vaults. Declared inline (returning `address`) rather than importing +/// the upstream `IAuthorizableV1` so this library owns its only external +/// surface and doesn't carry the upstream's richer return type. +interface IAuthorisable { + /// @notice The authoriser contract gating restricted vault operations. + /// @return The authoriser address. + function authorizer() external view returns (address); +} + +/// @notice A production receipt vault's `owner()` does not match the owner +/// the uniform-ownership invariant expected every vault to share. Surfaces +/// the exact vault address that breaks the invariant rather than a generic +/// mismatch. +/// @param vault The receipt vault whose owner was read. +/// @param expected The address every vault is expected to report as +/// `owner()`. +/// @param actual The owner address returned by `vault.owner()`. +error ReceiptVaultOwnerMismatch(address vault, address expected, address actual); + +/// @notice A production receipt vault's `authorizer()` does not match the +/// authoriser every vault is expected to share. Surfaces the exact vault +/// that breaks the uniform-authoriser invariant. +/// @param vault The receipt vault whose authoriser was read. +/// @param expected The authoriser address every vault is expected to share. +/// @param actual The authoriser address returned by `vault.authorizer()`. +error ReceiptVaultAuthoriserMismatch(address vault, address expected, address actual); + +/// @title LibTokenInvariants +/// @notice Reusable token-side uniformity invariants for the ST0x +/// production receipt vaults on Base. Each assertion iterates the vault +/// list emitted by `LibProdTokensBase.productionReceiptVaults` and either +/// returns silently when the invariant holds against the live chain state +/// or reverts with a typed error that pinpoints the offending vault. +/// @dev These are token-side prod invariants: a receipt vault's owner and +/// authoriser uniformity is a property of the token deployment, not of the +/// Safe multisig. They are composed into `LibSafeInvariants.assertAll` +/// alongside the Safe-side invariants so the full production state bundle +/// carries both, but they live here because owner/authoriser uniformity is +/// a token-side concern that does not belong in a Safe-named library. Both +/// asserts are also callable standalone for focused drift detection. +library LibTokenInvariants { + /// @notice Assert that every production receipt vault reports the same + /// `owner()`. Iterates `LibProdTokensBase.productionReceiptVaults` and + /// reverts with `ReceiptVaultOwnerMismatch` on the first vault whose + /// `owner()` diverges from `expectedOwner`, surfacing the offending + /// vault. + /// @dev A divergent owner means a token is controlled by a different + /// account than the rest of the system — the class of inconsistency + /// this invariant exists to prevent. Composed into + /// `LibSafeInvariants.assertAll` (with the Safe as the expected owner) + /// as a first-class token-side invariant; also callable standalone. + /// @param expectedOwner The address every production receipt vault is + /// expected to report as `owner()`. + function assertUniformOwnership(address expectedOwner) internal view { + address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); + for (uint256 i = 0; i < vaults.length; i++) { + address actualOwner = IOwnable(vaults[i]).owner(); + if (actualOwner != expectedOwner) { + revert ReceiptVaultOwnerMismatch(vaults[i], expectedOwner, actualOwner); + } + } + } + + /// @notice Assert that every production receipt vault reports the same + /// authoriser. Iterates `LibProdTokensBase.productionReceiptVaults` and + /// reverts with `ReceiptVaultAuthoriserMismatch` on the first vault whose + /// `authorizer()` diverges from `expected`, surfacing the offending vault. + /// @dev A divergent authoriser means a token is gated by a different RBAC + /// contract than the rest of the system — the class of inconsistency this + /// invariant exists to prevent. Composed into + /// `LibSafeInvariants.assertAll` as a first-class token-side invariant; + /// also callable standalone. + /// @param expected The authoriser address every production receipt vault + /// is expected to share. + function assertUniformAuthoriser(address expected) internal view { + address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); + for (uint256 i = 0; i < vaults.length; i++) { + address actual = IAuthorisable(vaults[i]).authorizer(); + if (actual != expected) { + revert ReceiptVaultAuthoriserMismatch(vaults[i], expected, actual); + } + } + } +} diff --git a/test/script/MigrateMultisigThresholdTest.t.sol b/test/script/MigrateMultisigThresholdTest.t.sol index 99d5e38a..bbf4880c 100644 --- a/test/script/MigrateMultisigThresholdTest.t.sol +++ b/test/script/MigrateMultisigThresholdTest.t.sol @@ -11,12 +11,8 @@ import { import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; import {LibProdSafes} from "../../src/lib/LibProdSafes.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; -import { - LibSafeInvariants, - IOwnable, - SafeThresholdMismatch, - ReceiptVaultOwnerMismatch -} from "../../src/lib/LibSafeInvariants.sol"; +import {LibSafeInvariants, SafeThresholdMismatch} from "../../src/lib/LibSafeInvariants.sol"; +import {IOwnable, ReceiptVaultOwnerMismatch} from "../../src/lib/LibTokenInvariants.sol"; import {LibProdTokensBase} from "../../src/lib/LibProdTokensBase.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; diff --git a/test/src/lib/LibProdAuthoriserUniformity.t.sol b/test/src/lib/LibProdAuthoriserUniformity.t.sol deleted file mode 100644 index 4e6cf4e8..00000000 --- a/test/src/lib/LibProdAuthoriserUniformity.t.sol +++ /dev/null @@ -1,38 +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 {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; - -/// @title LibProdAuthoriserUniformityTest -/// @notice Focused pin on the uniform-authoriser invariant: every production -/// receipt vault on Base shares `PROD_RECEIPT_VAULT_AUTHORISER`. -/// -/// This is a first-class token-side invariant — it is also folded into -/// `assertImmutableInvariants` (and therefore `assertAll`), so the production -/// Safe invariant suite carries it too. This file exists as a named, -/// standalone signal for this specific drift surface. -/// -/// It may be transiently red: at the time of writing one vault (IBHG) is -/// being migrated onto the shared authoriser. Once that lands on-chain this -/// greens automatically with no code change. The unpinned head fork means the -/// next CI run is the canary for the migration completing. -contract LibProdAuthoriserUniformityTest is Test { - /// @notice Selects the Base fork at chain head — deliberately unpinned, so - /// the next CI run reflects the current on-chain authoriser wiring. Matches - /// the unpinned head-fork convention used by the other prod-state drift - /// detectors in this repo. - function selectBaseFork() internal { - vm.createSelectFork(LibRainDeploy.BASE); - } - - /// @notice Every production receipt vault reports - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. - function testProdReceiptVaultsShareUniformAuthoriser() external { - selectBaseFork(); - LibSafeInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); - } -} diff --git a/test/src/lib/LibSafeInvariants.t.sol b/test/src/lib/LibSafeInvariants.t.sol index d834ff29..100f9021 100644 --- a/test/src/lib/LibSafeInvariants.t.sol +++ b/test/src/lib/LibSafeInvariants.t.sol @@ -6,12 +6,9 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibSafeInvariantsHarness} from "./LibSafeInvariantsHarness.sol"; import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import { - IOwnable, - ReceiptVaultOwnerMismatch, SafeProxyCodehashMismatch, SafeSingletonMismatch, SafeSingletonBytecodeMismatch, @@ -54,20 +51,6 @@ contract LibSafeInvariantsTest is Test { harness = new LibSafeInvariantsHarness(); } - /// @notice Token-side ownership drift bubbles - /// `ReceiptVaultOwnerMismatch` through `assertImmutableInvariants`. - /// Confirms the token-ownership leg is exercised by the immutable - /// bundle. The victim vault address comes from `LibProdTokensBase` - /// (the source of truth for production receipt vaults). - function testInvertedImmutableInvariantsTokenOwnershipDrift() external { - selectBaseFork(); - address rogueOwner = address(0xBADC0DE); - address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; - vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); - vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, address(safe), rogueOwner)); - harness.callAssertImmutableInvariants(safe); - } - /// @notice Drift in the proxy runtime codehash trips /// `SafeProxyCodehashMismatch`. Simulated by overwriting the proxy /// bytecode with a single `INVALID` opcode; `extcodehash` then returns diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol new file mode 100644 index 00000000..ea0d8104 --- /dev/null +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -0,0 +1,71 @@ +// 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 {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; +import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; +import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; +import {LibTokenInvariantsHarness} from "./LibTokenInvariantsHarness.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibTokenInvariantsTest +/// @notice Fork tests for the token-side uniformity invariants: every +/// production receipt vault on Base shares the same `owner()` and the same +/// `authorizer()`. +/// +/// The uniform-ownership invariant currently holds on-chain (every vault is +/// owned by `LibProdSafes.STOX_TOKEN_OWNER_SAFE`), so the positive ownership +/// case passes. The uniform-authoriser invariant may be transiently red: at +/// the time of writing one vault (IBHG) is being migrated onto the shared +/// authoriser. Once that lands on-chain the authoriser test greens +/// automatically with no code change. The inverted ownership-drift case is +/// also exercised here for full error-path coverage. +/// @dev Uses an unpinned Base head fork (same precedent as the other +/// prod-state drift detectors in this repo), so the next CI run reflects the +/// current on-chain wiring. Pinning would freeze the invariant assertions +/// against a stale snapshot and let new drift slip through unnoticed. +contract LibTokenInvariantsTest is Test { + /// @notice External-call harness deployed fresh per test against the + /// active fork. + LibTokenInvariantsHarness internal harness; + + /// @notice Selects the Base fork at chain head — deliberately unpinned. + /// Live drift detector; see contract-level rationale. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + harness = new LibTokenInvariantsHarness(); + } + + /// @notice Every production receipt vault reports + /// `LibProdSafes.STOX_TOKEN_OWNER_SAFE` as its `owner()`. Passes against + /// the live chain state: vault ownership is uniform. + function testProdReceiptVaultsUniformOwnership() external { + selectBaseFork(); + LibTokenInvariants.assertUniformOwnership(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + } + + /// @notice Every production receipt vault reports + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. May be transiently + /// red while IBHG's authoriser is migrated on-chain; greens automatically + /// once that lands. + function testProdReceiptVaultsShareUniformAuthoriser() external { + selectBaseFork(); + LibTokenInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); + } + + /// @notice Token-side ownership drift trips `ReceiptVaultOwnerMismatch`. + /// Simulated by mocking a single vault's `owner()` to a rogue address; + /// the assertion reverts surfacing the offending vault. The victim vault + /// address comes from `LibProdTokensBase` (the source of truth for + /// production receipt vaults). + function testInvertedUniformOwnershipDrift() external { + selectBaseFork(); + address expectedOwner = LibProdSafes.STOX_TOKEN_OWNER_SAFE; + address rogueOwner = address(0xBADC0DE); + address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; + vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); + vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, expectedOwner, rogueOwner)); + harness.callAssertUniformOwnership(expectedOwner); + } +} diff --git a/test/src/lib/LibTokenInvariantsHarness.sol b/test/src/lib/LibTokenInvariantsHarness.sol new file mode 100644 index 00000000..8afe5f0b --- /dev/null +++ b/test/src/lib/LibTokenInvariantsHarness.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibTokenInvariants} from "../../../src/lib/LibTokenInvariants.sol"; + +/// @title LibTokenInvariantsHarness +/// @notice External-call shim around the internal library so +/// `vm.expectRevert` can intercept the typed errors. `vm.expectRevert` only +/// catches reverts from external calls; library `internal` functions inline +/// and would fail the depth check otherwise. +contract LibTokenInvariantsHarness { + function callAssertUniformOwnership(address expectedOwner) external view { + LibTokenInvariants.assertUniformOwnership(expectedOwner); + } + + function callAssertUniformAuthoriser(address expected) external view { + LibTokenInvariants.assertUniformAuthoriser(expected); + } +} From a2861dd0687347a257773388da1b5c753c6f79c5 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 1 Jun 2026 16:06:09 +0000 Subject: [PATCH 04/11] refactor(safe): split LibSafeInvariants.assertAll into LibInvariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LibSafeInvariants.assertAll was reaching into LibTokenInvariants to bundle the token-side uniformity checks. That's a cross-lib coupling that doesn't belong in a Safe-named lib — assertAll there should be "assert all the Safe invariants," nothing else. Strip the two LibTokenInvariants calls from both assertAll overloads in LibSafeInvariants so the file name no longer lies. Add an assertAll to LibTokenInvariants that composes the two uniformity checks (vault ownership against the Safe + vault authoriser against the pinned prod authoriser) so the token-side bundle is reachable as a single call. Introduce LibInvariants as the orchestrator that composes every per-facet assertAll into the full production-state bundle; any future LibXInvariants slots in here without any existing facet lib having to know about it. Consumers (MigrateMultisigThreshold.s.sol, StoxProdV2.t.sol) move from LibSafeInvariants.assertAll to LibInvariants.assertAll so they keep the same full-production-state coverage they had before this split. LibSafeInvariants.t.sol stays Safe-only because that's the lib it exercises; LibTokenInvariants.t.sol stays token-side-only. Co-Authored-By: Claude Opus 4.7 --- script/MigrateMultisigThreshold.s.sol | 10 ++--- src/lib/LibInvariants.sol | 53 +++++++++++++++++++++++ src/lib/LibSafeInvariants.sol | 52 +++++++--------------- src/lib/LibTokenInvariants.sol | 37 +++++++++++----- test/src/concrete/deploy/StoxProdV2.t.sol | 9 ++-- test/src/lib/LibTokenInvariants.t.sol | 17 +++----- 6 files changed, 112 insertions(+), 66 deletions(-) create mode 100644 src/lib/LibInvariants.sol diff --git a/script/MigrateMultisigThreshold.s.sol b/script/MigrateMultisigThreshold.s.sol index e12cd3d5..41fd7ccd 100644 --- a/script/MigrateMultisigThreshold.s.sol +++ b/script/MigrateMultisigThreshold.s.sol @@ -7,7 +7,7 @@ import {console2} from "forge-std-1.16.1/src/console2.sol"; import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; import {LibProdSafes} from "../src/lib/LibProdSafes.sol"; -import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; +import {LibInvariants} from "../src/lib/LibInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; /// @notice A previously emitted Tx Builder JSON artifact (parsed via @@ -28,7 +28,7 @@ error VerifyExpectedSingleTx(uint256 actualCount); /// @title MigrateMultisigThreshold /// @notice Forge script that authors the ST0x token-owner Safe's /// multisig threshold migration (1-of-6 -> 3-of-6 against the post-rotation roster). Performs an -/// exhaustive on-chain pre-flight via `LibSafeInvariants.assertAll` +/// exhaustive on-chain pre-flight via `LibInvariants.assertAll` /// (proxy codehash, singleton + bytecode, version, modules, guard, /// fallback handler, uniform vault ownership, expected owner set, /// expected threshold), simulates the post-state, emits a Safe Tx @@ -78,7 +78,7 @@ contract MigrateMultisigThreshold is Script { // roster plus the pinned current threshold. Defaults from // `LibProdSafes` (no-arg overload). Reverts with the relevant // typed error from the underlying library on first mismatch. - LibSafeInvariants.assertAll(safe); + LibInvariants.assertAll(safe); // Build the single-tx bundle: a self-call to `changeThreshold(3)`. SafeTx memory txn = SafeTx({ @@ -104,7 +104,7 @@ contract MigrateMultisigThreshold is Script { // implementation that secretly mutates the owner roster, modules, // or fallback handler as a side effect. LibSafeOps.simulateSelfCall(safe, txn.data); - LibSafeInvariants.assertAll(safe, TARGET_THRESHOLD, LibProdSafes.expectedOwners()); + LibInvariants.assertAll(safe, TARGET_THRESHOLD, LibProdSafes.expectedOwners()); // Emit the Tx Builder JSON artifact and write it under `out/`. SafeTx[] memory txs = new SafeTx[](1); @@ -147,7 +147,7 @@ contract MigrateMultisigThreshold is Script { // Same pre-flight bundle as `run()`. If the live state has drifted // since the artifact was authored, the typed error bubbles before // we even open the file. - LibSafeInvariants.assertAll(safe); + LibInvariants.assertAll(safe); (uint256 parsedChainId, address parsedSafe, SafeTx[] memory parsedTxs) = LibSafeOps.parseTxBuilderJson(jsonPath); diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol new file mode 100644 index 00000000..e66d454b --- /dev/null +++ b/src/lib/LibInvariants.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; +import {LibProdSafes} from "./LibProdSafes.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; +import {LibTokenInvariants} from "./LibTokenInvariants.sol"; + +/// @title LibInvariants +/// @notice Orchestrator that composes every per-facet `assertAll` into a +/// single bundle. Each facet lib (`LibSafeInvariants`, `LibTokenInvariants`, +/// any future `LibInvariants`) owns its own `assertAll`; this lib +/// chains them so a consumer asserting the full production state has a +/// single call site without any facet lib having to know about other +/// facets. +/// @dev Lives separately from `LibSafeInvariants` so the file name doesn't +/// lie about scope: cross-facet composition belongs in a cross-facet lib, +/// not inside a Safe-named lib. Per-facet libs stay focused on their +/// subject and reachable standalone for scripts / fork tests that don't +/// need the full bundle. +library LibInvariants { + /// @notice Full production-state invariant bundle. Composes every + /// per-facet `assertAll`: Safe identity / config + token-side + /// owner/authoriser uniformity. Pre-flight at the start of every + /// migration script and prod-state fork test; if this passes silently + /// the live system is in its current expected state across every + /// pinned facet. + /// @dev The full-args overload is the right call site only when a + /// caller is *deliberately* asserting a state that diverges from the + /// pinned current truth (e.g. a migration script's post-state re-check + /// after it has simulated `changeThreshold`); the no-arg overload + /// fills in the `LibProdSafes`-pinned defaults. + /// @param safe The Safe to validate against the pinned current truth. + function assertAll(IGnosisSafe safe) internal view { + LibSafeInvariants.assertAll(safe); + LibTokenInvariants.assertAll(address(safe)); + } + + /// @notice Full-args bundle. Use when overriding the Safe-side + /// threshold or owner set from `LibProdSafes`' current-truth pins — + /// typically only when running a script that intentionally changes + /// one of those (post-state assertion). The token-side leg always + /// uses the pinned defaults (vault ownership against the Safe, + /// authoriser against `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`). + /// @param safe The Safe to validate. + /// @param expectedThreshold The expected signature threshold. + /// @param expectedOwners The expected owner set in `getOwners()` order. + function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { + LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); + LibTokenInvariants.assertAll(address(safe)); + } +} diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index c33b32c9..5dc43e34 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -4,8 +4,6 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; import {LibProdSafes} from "./LibProdSafes.sol"; -import {LibProdTokensBase} from "./LibProdTokensBase.sol"; -import {LibTokenInvariants} from "./LibTokenInvariants.sol"; /// @notice The runtime codehash at the Safe's address does not match the /// pinned Safe v1.4.1 L2 proxy codehash. Signals either that the address has @@ -120,16 +118,14 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// threshold migration). Wrong values here are caller intent, not Safe /// drift, so the comparison target is an argument. /// -/// The `assertAll` overloads bundle the Safe-side immutable invariants, -/// the two parameterised checks, and the token-side uniformity invariants -/// (`LibTokenInvariants.assertUniformOwnership` / -/// `assertUniformAuthoriser`) into a single call site. The pattern mirrors -/// `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that takes -/// every expected value, and a no-arg default that fills in the -/// current-truth pins from `LibProdSafes`. Scripts default to the no-arg -/// version for pre-flight; only the migration script with deliberate -/// state changes uses the full-args overload for its post-state -/// assertion. +/// The `assertAll` overloads bundle the Safe-side immutable invariants +/// and the two parameterised checks into a single call site. The pattern +/// mirrors `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that +/// takes every expected value, and a no-arg default that fills in the +/// current-truth pins from `LibProdSafes`. Token-side invariants are +/// composed alongside these by `LibInvariants.assertAll` for callers +/// asserting the full production state; this lib is Safe-only by design +/// so the file name doesn't mislead. /// /// Centralising the assertions here keeps drift detection consistent /// across the threshold migration script, its tests, the post-migration @@ -304,35 +300,21 @@ library LibSafeInvariants { } } - /// @notice Full-args invariant bundle. Use when you want to override - /// the expected threshold or owner set from the `LibProdSafes` + /// @notice Full-args Safe-side invariant bundle. Use when you want to + /// override the expected threshold or owner set from the `LibProdSafes` /// current-truth pins — typically only when running a script that /// intentionally changes one of those (post-state assertion). - /// @dev Composes both the Safe-side invariants (immutable Safe - /// identity/config, owner set, threshold) and the token-side - /// uniformity invariants (`LibTokenInvariants.assertUniformOwnership` - /// against the Safe, and `assertUniformAuthoriser` against the pinned - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`). The token legs - /// live in `LibTokenInvariants` because vault owner/authoriser - /// uniformity is a property of the token deployment, but they are - /// bundled here so a consumer asserting the full production state never - /// has to remember to run them separately. + /// @dev Composes the Safe-side invariants only: immutable Safe + /// identity/config, owner set, and threshold. Token-side uniformity + /// invariants are composed in `LibInvariants.assertAll` so the + /// full-production-state bundle still exists, but they don't live + /// here — this lib is purely Safe-side. /// /// Mirrors the `StoxProdV2Test::checkAllV2OnChain` pattern: a /// full-args helper alongside a no-arg overload. Migration scripts /// call the no-arg overload pre-execution to assert the pinned /// current truth, then call this overload post-execution with the /// deliberately-changed expectation. - /// - /// Implementation is intentionally a thin wrapper rather than a - /// separate body: keeping each underlying check addressable in - /// isolation lets fork tests exercise individual drift surfaces, and - /// keeping the bundle alongside them means migration code never has - /// to remember which of the pieces to run. The token-side ownership - /// leg is asserted against `address(safe)`, so it also surfaces vault - /// ownership drift against the Safe at migration time. Both token legs - /// run last because they are the most expensive (13 external calls - /// each) and only meaningful once the Safe itself has been validated. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. @@ -340,11 +322,9 @@ library LibSafeInvariants { assertImmutableInvariants(safe); assertOwnerSet(safe, expectedOwners); assertThreshold(safe, expectedThreshold); - LibTokenInvariants.assertUniformOwnership(address(safe)); - LibTokenInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); } - /// @notice No-arg invariant bundle that fills in the + /// @notice No-arg Safe-side invariant bundle that fills in the /// `LibProdSafes`-pinned current-truth defaults: the threshold from /// `STOX_TOKEN_OWNER_SAFE_THRESHOLD` and the owner set from /// `expectedOwners()`. Pre-flight at the start of every script and diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index f12ee1f1..06b64938 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -51,11 +51,10 @@ error ReceiptVaultAuthoriserMismatch(address vault, address expected, address ac /// or reverts with a typed error that pinpoints the offending vault. /// @dev These are token-side prod invariants: a receipt vault's owner and /// authoriser uniformity is a property of the token deployment, not of the -/// Safe multisig. They are composed into `LibSafeInvariants.assertAll` -/// alongside the Safe-side invariants so the full production state bundle -/// carries both, but they live here because owner/authoriser uniformity is -/// a token-side concern that does not belong in a Safe-named library. Both -/// asserts are also callable standalone for focused drift detection. +/// Safe multisig. `LibInvariants.assertAll` composes this lib's `assertAll` +/// alongside `LibSafeInvariants.assertAll` so consumers asserting the full +/// production state get both. Individual asserts are also callable +/// standalone for focused drift detection. library LibTokenInvariants { /// @notice Assert that every production receipt vault reports the same /// `owner()`. Iterates `LibProdTokensBase.productionReceiptVaults` and @@ -64,9 +63,9 @@ library LibTokenInvariants { /// vault. /// @dev A divergent owner means a token is controlled by a different /// account than the rest of the system — the class of inconsistency - /// this invariant exists to prevent. Composed into - /// `LibSafeInvariants.assertAll` (with the Safe as the expected owner) - /// as a first-class token-side invariant; also callable standalone. + /// this invariant exists to prevent. Composed into `assertAll` (with + /// the Safe as the expected owner) and through there into + /// `LibInvariants.assertAll`; also callable standalone. /// @param expectedOwner The address every production receipt vault is /// expected to report as `owner()`. function assertUniformOwnership(address expectedOwner) internal view { @@ -85,9 +84,8 @@ library LibTokenInvariants { /// `authorizer()` diverges from `expected`, surfacing the offending vault. /// @dev A divergent authoriser means a token is gated by a different RBAC /// contract than the rest of the system — the class of inconsistency this - /// invariant exists to prevent. Composed into - /// `LibSafeInvariants.assertAll` as a first-class token-side invariant; - /// also callable standalone. + /// invariant exists to prevent. Composed into `assertAll` and through + /// there into `LibInvariants.assertAll`; also callable standalone. /// @param expected The authoriser address every production receipt vault /// is expected to share. function assertUniformAuthoriser(address expected) internal view { @@ -99,4 +97,21 @@ library LibTokenInvariants { } } } + + /// @notice Full token-side invariant bundle: every production receipt + /// vault reports the Safe as its `owner()` AND the pinned production + /// authoriser as its `authorizer()`. Pre-flight / post-state hook for + /// any script touching the production receipt vault set; consumers + /// asserting the full production state (Safe + token) compose this + /// alongside `LibSafeInvariants.assertAll` via `LibInvariants.assertAll`. + /// @dev Both legs run last in the composed bundle because each is + /// `O(13)` external calls and only meaningful once the Safe itself has + /// been validated. + /// @param safe The Safe address every production receipt vault is + /// expected to report as `owner()`. The authoriser is sourced from + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. + function assertAll(address safe) internal view { + assertUniformOwnership(safe); + assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); + } } diff --git a/test/src/concrete/deploy/StoxProdV2.t.sol b/test/src/concrete/deploy/StoxProdV2.t.sol index 4dab587e..40e771f9 100644 --- a/test/src/concrete/deploy/StoxProdV2.t.sol +++ b/test/src/concrete/deploy/StoxProdV2.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdDeployV2} from "../../../../src/lib/LibProdDeployV2.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; import {LibProdSafes} from "../../../../src/lib/LibProdSafes.sol"; -import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; +import {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; import {IGnosisSafe} from "../../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol"; @@ -122,12 +122,13 @@ contract StoxProdV2Test is Test { } /// Per-Safe invariant bundle for the ST0x token-owner Safe on Base. - /// Calls `LibSafeInvariants.assertAll` against the production Safe - /// address pinned in `LibProdSafes`. The Safe is Base-only (no Safe + /// Calls `LibInvariants.assertAll` against the production Safe + /// address pinned in `LibProdSafes` — composes the Safe-side and + /// token-side invariants in one call. The Safe is Base-only (no Safe /// on Arbitrum / Base Sepolia / Flare / Polygon for ST0x ops), so /// this helper is only invoked from `testProdDeployBaseV2`. function checkAllSafeBase() internal view { - LibSafeInvariants.assertAll(IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE)); + LibInvariants.assertAll(IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE)); } /// All V2 contracts MUST be deployed on Arbitrum. diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol index ea0d8104..7920725a 100644 --- a/test/src/lib/LibTokenInvariants.t.sol +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -14,13 +14,11 @@ import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// production receipt vault on Base shares the same `owner()` and the same /// `authorizer()`. /// -/// The uniform-ownership invariant currently holds on-chain (every vault is -/// owned by `LibProdSafes.STOX_TOKEN_OWNER_SAFE`), so the positive ownership -/// case passes. The uniform-authoriser invariant may be transiently red: at -/// the time of writing one vault (IBHG) is being migrated onto the shared -/// authoriser. Once that lands on-chain the authoriser test greens -/// automatically with no code change. The inverted ownership-drift case is -/// also exercised here for full error-path coverage. +/// Both uniformity invariants currently hold on-chain (every vault is +/// owned by `LibProdSafes.STOX_TOKEN_OWNER_SAFE` and reports the pinned +/// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`), so the positive +/// cases pass against the live Base fork. The inverted ownership-drift +/// case is also exercised here for full error-path coverage. /// @dev Uses an unpinned Base head fork (same precedent as the other /// prod-state drift detectors in this repo), so the next CI run reflects the /// current on-chain wiring. Pinning would freeze the invariant assertions @@ -46,9 +44,8 @@ contract LibTokenInvariantsTest is Test { } /// @notice Every production receipt vault reports - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. May be transiently - /// red while IBHG's authoriser is migrated on-chain; greens automatically - /// once that lands. + /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. Passes against + /// the live chain state: vault authoriser is uniform. function testProdReceiptVaultsShareUniformAuthoriser() external { selectBaseFork(); LibTokenInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); From b3086fe64ab4aaa2115101774d465885ab3fb16d Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 1 Jun 2026 17:55:57 +0000 Subject: [PATCH 05/11] refactor(safe): merge LibProdSafes + LibProdTokensBase into their invariant libs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the LibProdAuthoriser → LibAuthoriserInvariants pattern (#201) symmetrically to the remaining LibProd* libs. Each invariant lib now owns both the pinned current-state addresses and the assert functions that target them, dropping the LibProdX / LibXInvariants split that was making file names lie about scope. LibSafeInvariants gains everything from LibProdSafes: - SAFE_V1_4_1_L2_SINGLETON + SAFE_V1_4_1_L2_PROXY_CODEHASH + version + singleton codehash + CompatibilityFallbackHandler (deployment manifest constants). - STOX_TOKEN_OWNER_SAFE + threshold + OWNER_1..4 + expectedOwners() (current ST0x Safe state pins). LibTokenInvariants gains everything from LibProdTokensBase: - 13 × (RECEIPT + RECEIPT_VAULT + WRAPPED_TOKEN_VAULT) address constants. - productionReceiptVaults() helper. - STOX_PROD_AUTHORISER constant (the prod-authoriser pin used by assertUniformAuthoriser as the expected value). On #201 this constant moves to LibAuthoriserInvariants and LibTokenInvariants drops it, matching the parameterised assertAll signature. - assertAll(safe, expectedAuthoriser) — caller supplies the authoriser rather than the lib hardcoding it, keeping LibTokenInvariants free of cross-facet dependencies. BEACON_PRE_MIGRATION_OWNER (which was in LibProdSafes despite being beacon-related) is dropped. It's a literal duplicate of LibProdDeployV1.BEACON_INITIAL_OWNER; consumers use that directly. LibProdSafes.sol and LibProdTokensBase.sol deleted. test/src/lib/ LibProdTokensBase.t.sol renamed to LibTokenInvariants.addresses.t.sol (matches the lib it exercises). All 8 consumers on this branch updated: import paths, library prefixes, and the BEACON_PRE_MIGRATION_OWNER references replaced with LibProdDeployV1.BEACON_INITIAL_OWNER. Co-Authored-By: Claude Opus 4.7 --- script/MigrateMultisigThreshold.s.sol | 14 +- src/lib/LibInvariants.sol | 15 +- src/lib/LibProdSafes.sol | 139 ---------- src/lib/LibProdTokensBase.sol | 250 ------------------ src/lib/LibSafeInvariants.sol | 157 +++++++++-- src/lib/LibTokenInvariants.sol | 176 ++++++++++-- .../script/MigrateMultisigThresholdTest.t.sol | 12 +- test/src/concrete/deploy/StoxProdV2.t.sol | 6 +- test/src/lib/LibSafeInvariants.t.sol | 40 +-- test/src/lib/LibSafeOps.t.sol | 14 +- ...sol => LibTokenInvariants.addresses.t.sol} | 86 +++--- test/src/lib/LibTokenInvariants.t.sol | 21 +- 12 files changed, 399 insertions(+), 531 deletions(-) delete mode 100644 src/lib/LibProdSafes.sol delete mode 100644 src/lib/LibProdTokensBase.sol rename test/src/lib/{LibProdTokensBase.t.sol => LibTokenInvariants.addresses.t.sol} (92%) diff --git a/script/MigrateMultisigThreshold.s.sol b/script/MigrateMultisigThreshold.s.sol index 41fd7ccd..46f43e02 100644 --- a/script/MigrateMultisigThreshold.s.sol +++ b/script/MigrateMultisigThreshold.s.sol @@ -6,7 +6,7 @@ import {Script} from "forge-std-1.16.1/src/Script.sol"; import {console2} from "forge-std-1.16.1/src/console2.sol"; import {IGnosisSafe} from "../src/interface/IGnosisSafe.sol"; -import {LibProdSafes} from "../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../src/lib/LibSafeInvariants.sol"; import {LibInvariants} from "../src/lib/LibInvariants.sol"; import {LibSafeOps, SafeTx} from "../src/lib/LibSafeOps.sol"; @@ -44,7 +44,7 @@ error VerifyExpectedSingleTx(uint256 actualCount); /// artifact wasn't tampered with between authoring and signing. /// /// Pre-flight uses the no-arg `assertAll(safe)` overload, which defaults -/// the expected threshold and owner set to the `LibProdSafes`-pinned +/// the expected threshold and owner set to the `LibSafeInvariants`-pinned /// current truth. Post-state uses the full-args overload to override /// the threshold with the deliberately-changed `TARGET_THRESHOLD` /// while keeping the owner set pinned. @@ -73,10 +73,10 @@ contract MigrateMultisigThreshold is Script { /// verification in production and we explicitly simulate via /// `vm.prank`. function run() external { - IGnosisSafe safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); // Pre-flight: every immutable invariant plus the pinned 6-owner // roster plus the pinned current threshold. Defaults from - // `LibProdSafes` (no-arg overload). Reverts with the relevant + // `LibSafeInvariants` (no-arg overload). Reverts with the relevant // typed error from the underlying library on first mismatch. LibInvariants.assertAll(safe); @@ -104,7 +104,7 @@ contract MigrateMultisigThreshold is Script { // implementation that secretly mutates the owner roster, modules, // or fallback handler as a side effect. LibSafeOps.simulateSelfCall(safe, txn.data); - LibInvariants.assertAll(safe, TARGET_THRESHOLD, LibProdSafes.expectedOwners()); + LibInvariants.assertAll(safe, TARGET_THRESHOLD, LibSafeInvariants.expectedOwners()); // Emit the Tx Builder JSON artifact and write it under `out/`. SafeTx[] memory txs = new SafeTx[](1); @@ -133,7 +133,7 @@ contract MigrateMultisigThreshold is Script { // forward migration (`changeThreshold(3)`) rather than the // reversal. The reversal exists only as a fork-local simulation; // signers never see it. - LibSafeOps.simulateNPlus1Reversal(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, TARGET_THRESHOLD); + LibSafeOps.simulateNPlus1Reversal(safe, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, TARGET_THRESHOLD); console2.log("n+1 reversibility check passed: threshold reverted to", safe.getThreshold()); } @@ -143,7 +143,7 @@ contract MigrateMultisigThreshold is Script { /// integrity before signing. /// @param jsonPath Filesystem path to the Tx Builder JSON to verify. function verify(string calldata jsonPath) external view { - IGnosisSafe safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); // Same pre-flight bundle as `run()`. If the live state has drifted // since the artifact was authored, the typed error bubbles before // we even open the file. diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol index e66d454b..d0c4cb72 100644 --- a/src/lib/LibInvariants.sol +++ b/src/lib/LibInvariants.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; -import {LibProdSafes} from "./LibProdSafes.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; import {LibTokenInvariants} from "./LibTokenInvariants.sol"; @@ -30,24 +29,24 @@ library LibInvariants { /// caller is *deliberately* asserting a state that diverges from the /// pinned current truth (e.g. a migration script's post-state re-check /// after it has simulated `changeThreshold`); the no-arg overload - /// fills in the `LibProdSafes`-pinned defaults. + /// fills in the `LibSafeInvariants`-pinned defaults. /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { LibSafeInvariants.assertAll(safe); - LibTokenInvariants.assertAll(address(safe)); + LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); } /// @notice Full-args bundle. Use when overriding the Safe-side - /// threshold or owner set from `LibProdSafes`' current-truth pins — + /// threshold or owner set from `LibSafeInvariants`' current-truth pins — /// typically only when running a script that intentionally changes - /// one of those (post-state assertion). The token-side leg always - /// uses the pinned defaults (vault ownership against the Safe, - /// authoriser against `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`). + /// one of those (post-state assertion). The token-side leg uses the + /// pinned defaults (vault ownership against the Safe, authoriser + /// against `LibTokenInvariants.STOX_PROD_AUTHORISER`). /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); - LibTokenInvariants.assertAll(address(safe)); + LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); } } diff --git a/src/lib/LibProdSafes.sol b/src/lib/LibProdSafes.sol deleted file mode 100644 index aca1999d..00000000 --- a/src/lib/LibProdSafes.sol +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -/// @title LibProdSafes -/// @notice Production Safe constants for the ST0x token-owner multisig on -/// Base. Pinned addresses, codehashes, slot values, and the expected owner -/// set (6 owners post-rotation) are derived from live on-chain state and -/// the canonical Safe deployment manifest, then re-asserted from fork -/// tests so that drift between this file and reality trips CI rather than -/// slipping into a migration script. -/// @dev Scope: this file pins the *post-rotation* roster (6 ST0x -/// governance signers) and the threshold migration script targets 3-of-6 -/// against that roster. The owner rotation itself is done manually via -/// the Safe UI under the current 1-of-N threshold (no rotation script -/// needed — at threshold 1 a single signer can execute each -/// `addOwnerWithThreshold` / `removeOwner` call via the Safe UI). Each -/// pinned address was verified off-chain via a send-back ceremony before -/// being committed here; the per-signer verification tracker is held -/// privately. Until the manual roster swap finishes executing on-chain -/// (i.e. all 6 listed addresses are present and the previous owners are -/// removed), every script and fork test that asserts against -/// `expectedOwners()` deliberately fails — the red CI is the explicit -/// forcing function gating the threshold-raise script. -/// @dev Sources: -/// - Safe v1.4.1 L2 singleton & proxy: github.com/safe-global/safe-deployments -/// under `src/assets/v1.4.1/safe_l2.json` (chainId 8453 entry). Both the -/// singleton address and the proxy bytecode are deterministic across the -/// Safe L2 deployment, so the proxy codehash below is also constant. -/// - ST0x Safe address & live state read on Base on 2026-05-20 via -/// `cast call`. The owner set, threshold, and storage-slot pins below -/// match the post-removal state. `StoxProdV2.t.sol::testProdDeployBaseV2` -/// exercises these against an unpinned head fork (via -/// `LibSafeInvariants.assertAll`) so the next CI run catches any further -/// drift; see that test for why `LibTestProd.PROD_TEST_BLOCK_NUMBER_BASE` -/// is not reused. -library LibProdSafes { - /// @notice Safe v1.4.1 L2 singleton (master copy) address on Base. - /// Verified by reading proxy storage slot `0x0` of - /// `STOX_TOKEN_OWNER_SAFE` and matching against the - /// `safe-deployments` manifest. - address constant SAFE_V1_4_1_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; - - /// @notice Runtime codehash of a Safe v1.4.1 proxy on Base. Equal to - /// `extcodehash(STOX_TOKEN_OWNER_SAFE)` and to every other v1.4.1 L2 - /// proxy pointing at `SAFE_V1_4_1_L2_SINGLETON`. Pinning this codehash - /// guards against the Safe address being replaced by an EOA-controlled - /// contract or a fake proxy pointing at a malicious singleton. - bytes32 constant SAFE_V1_4_1_L2_PROXY_CODEHASH = 0xb89c1b3bdf2cf8827818646bce9a8f6e372885f8c55e5c07acbd307cb133b000; - - /// @notice Expected `VERSION()` string from a Safe v1.4.1 singleton. - string constant SAFE_V1_4_1_VERSION = "1.4.1"; - - /// @notice Runtime codehash of the Safe v1.4.1 L2 singleton bytecode at - /// `SAFE_V1_4_1_L2_SINGLETON`. Pinning this guards against an attacker - /// who replaces the bytecode at the singleton address (e.g. via - /// `SELFDESTRUCT` + re-create) while preserving the proxy codehash. - /// Without this pin, every implementation-backed accessor on the Safe - /// (`VERSION()`, `getOwners()`, `getThreshold()`, etc.) is mediated by - /// untrusted code at the singleton address. Asserting this codehash - /// before any of those reads closes that gap. - /// @dev Computed via `keccak256(eth_getCode(SAFE_V1_4_1_L2_SINGLETON))` - /// on Base on 2026-05-20. - bytes32 constant SAFE_V1_4_1_L2_SINGLETON_CODEHASH = - 0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff; - - /// @notice CompatibilityFallbackHandler v1.4.1 address on Base. Verified - /// against the live Safe's fallback handler storage slot. Pinned so a - /// swapped-in malicious handler that shadows view selectors via - /// fallback can be detected by `LibSafeInvariants.assertImmutableInvariants`. - /// @dev Source: github.com/safe-global/safe-deployments - /// `src/assets/v1.4.1/compatibility_fallback_handler.json` (chainId - /// 8453 entry). Cross-checked on Base on 2026-05-20. - address constant SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER = 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99; - - /// @notice The Safe that owns every ST0x receipt vault on Base. Subject - /// of the threshold migration (1 -> 3, against the post-rotation - /// 6-owner roster). - /// https://basescan.org/address/0xe70d821f3462A074E63b42D0aac6523faAe1D611 - address constant STOX_TOKEN_OWNER_SAFE = 0xe70d821f3462a074e63b42d0AaC6523faAe1d611; - - /// @notice The current expected threshold for `STOX_TOKEN_OWNER_SAFE`. - /// Updated by the threshold-migration PR family once live execution - /// lands: scripts and the post-migration pin both treat this constant - /// as the canonical current truth, so the value bumps from `1` to `3` - /// in the same PR that records the live post-execution state. - uint256 constant STOX_TOKEN_OWNER_SAFE_THRESHOLD = 1; - - /// @notice Owner #1 of `STOX_TOKEN_OWNER_SAFE`. - /// @dev Order matches `getOwners()` (Safe-internal linked-list order) - /// against the post-rotation roster: `getOwners()` returns owners - /// newest-first, so the last signer to be added via - /// `addOwnerWithThreshold` appears at slot 0. The owner rotation - /// itself is done manually via the Safe UI under the current 1-of-N - /// threshold (no rotation script); only the threshold raise is - /// scripted. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_1 = 0x4746095B1Ea1A84446d34448f44e74D3d51f92F2; - - /// @notice Owner #2 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_2 = 0xceC2cb8B8EE4000FFA3F8a7f8E0Fa0A3E3DAb72d; - - /// @notice Owner #3 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_3 = 0x8D5901d8aE48101B59400235ad8614A2e0510466; - - /// @notice Owner #4 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_4 = 0xC1C89b7f5448F447d59f920456A9610f6b2544bC; - - /// @notice Owner #5 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_5 = 0xAB92b327c97A6E7461cBd76E2a789E5e106FF87e; - - /// @notice Owner #6 of `STOX_TOKEN_OWNER_SAFE`. - address constant STOX_TOKEN_OWNER_SAFE_OWNER_6 = 0x5CCd3cE683b66ff271DDB8915fF528b8fcFa23c2; - - /// @notice Returns the expected owner set for `STOX_TOKEN_OWNER_SAFE` in - /// the exact order returned by `getOwners()` against an unpinned Base - /// head fork (the live-state pin lives in - /// `StoxProdV2.t.sol::testProdDeployBaseV2`, which selects head rather - /// than pinning to a historical block so the next CI run catches any - /// further drift). Provided as a helper because Solidity 0.8 cannot - /// express a file-scope `constant address[]` and declaring the array - /// as `immutable` is contract-scoped only. - /// @dev Six entries post-rotation. The roster uses a mixed-vendor - /// hardware-wallet policy: the 3-of-6 threshold combined with the - /// vendor mix enforces that no single-vendor subset can reach quorum - /// on its own, so a single-vendor compromise cannot sign a tx without - /// recruiting a different-vendor signer. - /// @return The six owners of the ST0x token-owner Safe in - /// `getOwners()` order. - function expectedOwners() internal pure returns (address[] memory) { - address[] memory owners = new address[](6); - owners[0] = STOX_TOKEN_OWNER_SAFE_OWNER_1; - owners[1] = STOX_TOKEN_OWNER_SAFE_OWNER_2; - owners[2] = STOX_TOKEN_OWNER_SAFE_OWNER_3; - owners[3] = STOX_TOKEN_OWNER_SAFE_OWNER_4; - owners[4] = STOX_TOKEN_OWNER_SAFE_OWNER_5; - owners[5] = STOX_TOKEN_OWNER_SAFE_OWNER_6; - return owners; - } -} diff --git a/src/lib/LibProdTokensBase.sol b/src/lib/LibProdTokensBase.sol deleted file mode 100644 index a0ce9cbf..00000000 --- a/src/lib/LibProdTokensBase.sol +++ /dev/null @@ -1,250 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -/// @title LibProdTokensBase -/// @notice Production token instance addresses on Base. These are beacon proxy -/// instances created via the V1 deployer, not implementation contracts. -/// Each token set consists of a receipt (ERC-1155), receipt vault (ERC-20), -/// and wrapped token vault (ERC-4626). -library LibProdTokensBase { - // ========================================================================= - // tMSTR / wtMSTR — MicroStrategy Incorporated ST0x - // Deployed via V1 OffchainAssetReceiptVaultBeaconSetDeployer + V1 StoxWrappedTokenVaultBeaconSetDeployer - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tMSTR. - /// https://basescan.org/address/0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC - address constant MSTR_RECEIPT = address(0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC); - - /// @dev Receipt vault (ERC-20, "tMSTR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE - address constant MSTR_RECEIPT_VAULT = address(0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE); - - /// @dev Wrapped token vault (ERC-4626, "wtMSTR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2 - address constant MSTR_WRAPPED_TOKEN_VAULT = address(0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2); - - // ========================================================================= - // tTSLA / wtTSLA — Tesla Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tTSLA. - /// https://basescan.org/address/0x660923230fAA859622711a5fC80f532dd588b125 - address constant TSLA_RECEIPT = address(0x660923230fAA859622711a5fC80f532dd588b125); - - /// @dev Receipt vault (ERC-20, "tTSLA") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0 - address constant TSLA_RECEIPT_VAULT = address(0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0); - - /// @dev Wrapped token vault (ERC-4626, "wtTSLA") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D - address constant TSLA_WRAPPED_TOKEN_VAULT = address(0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D); - - // ========================================================================= - // tCOIN / wtCOIN — Coinbase Global Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tCOIN. - /// https://basescan.org/address/0xBA1B8836A5510815e96103F067715b7CCC7c2E0E - address constant COIN_RECEIPT = address(0xBA1B8836A5510815e96103F067715b7CCC7c2E0E); - - /// @dev Receipt vault (ERC-20, "tCOIN") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x626757e6F50675D17fcAd312E82f989aE7A23d38 - address constant COIN_RECEIPT_VAULT = address(0x626757e6F50675D17fcAd312E82f989aE7A23d38); - - /// @dev Wrapped token vault (ERC-4626, "wtCOIN") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x5cDa0E1CA4ce2af96315f7F8963C85399c172204 - address constant COIN_WRAPPED_TOKEN_VAULT = address(0x5cDa0E1CA4ce2af96315f7F8963C85399c172204); - - // ========================================================================= - // tSPYM / wtSPYM — State Street SPDR Portfolio S&P 500 ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSPYM. - /// https://basescan.org/address/0x957056dD6e2E594742E36675e8AA5A567163E5bd - address constant SPYM_RECEIPT = address(0x957056dD6e2E594742E36675e8AA5A567163E5bd); - - /// @dev Receipt vault (ERC-20, "tSPYM") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb - address constant SPYM_RECEIPT_VAULT = address(0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb); - - /// @dev Wrapped token vault (ERC-4626, "wtSPYM") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x31C2C14134e6E3B7ef9478297F199331133Fc2d8 - address constant SPYM_WRAPPED_TOKEN_VAULT = address(0x31C2C14134e6E3B7ef9478297F199331133Fc2d8); - - // ========================================================================= - // tSIVR / wtSIVR — abrdn Physical Silver Shares ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSIVR. - /// https://basescan.org/address/0x053F52109a3439b4F292056D2DceC0486B544e82 - address constant SIVR_RECEIPT = address(0x053F52109a3439b4F292056D2DceC0486B544e82); - - /// @dev Receipt vault (ERC-20, "tSIVR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8 - address constant SIVR_RECEIPT_VAULT = address(0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8); - - /// @dev Wrapped token vault (ERC-4626, "wtSIVR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F - address constant SIVR_WRAPPED_TOKEN_VAULT = address(0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F); - - // ========================================================================= - // tCRCL / wtCRCL — Circle Internet Group Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tCRCL. - /// https://basescan.org/address/0xd508B97975fBE04E62bFf18959549b046bD8FA78 - address constant CRCL_RECEIPT = address(0xd508B97975fBE04E62bFf18959549b046bD8FA78); - - /// @dev Receipt vault (ERC-20, "tCRCL") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52 - address constant CRCL_RECEIPT_VAULT = address(0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52); - - /// @dev Wrapped token vault (ERC-4626, "wtCRCL") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf - address constant CRCL_WRAPPED_TOKEN_VAULT = address(0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf); - - // ========================================================================= - // tNVDA / wtNVDA — NVIDIA Corporation ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tNVDA. - /// https://basescan.org/address/0x8Dd4c6f08E446075879310AFae8167CC4DE2f805 - address constant NVDA_RECEIPT = address(0x8Dd4c6f08E446075879310AFae8167CC4DE2f805); - - /// @dev Receipt vault (ERC-20, "tNVDA") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x7271A3C91Bb6070eD09333B84a815949D4f16d14 - address constant NVDA_RECEIPT_VAULT = address(0x7271A3C91Bb6070eD09333B84a815949D4f16d14); - - /// @dev Wrapped token vault (ERC-4626, "wtNVDA") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7 - address constant NVDA_WRAPPED_TOKEN_VAULT = address(0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7); - - // ========================================================================= - // tIAU / wtIAU — iShares Gold Trust ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tIAU. - /// https://basescan.org/address/0x9E128159ff53Ce113df52D760C032DD65DDb0E64 - address constant IAU_RECEIPT = address(0x9E128159ff53Ce113df52D760C032DD65DDb0E64); - - /// @dev Receipt vault (ERC-20, "tIAU") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF - address constant IAU_RECEIPT_VAULT = address(0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF); - - /// @dev Wrapped token vault (ERC-4626, "wtIAU") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8 - address constant IAU_WRAPPED_TOKEN_VAULT = address(0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8); - - // ========================================================================= - // tPPLT / wtPPLT — abrdn Physical Platinum Shares ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tPPLT. - /// https://basescan.org/address/0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6 - address constant PPLT_RECEIPT = address(0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6); - - /// @dev Receipt vault (ERC-20, "tPPLT") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x1f17523b147CcC2A2328c0F014f6d49c479ea063 - address constant PPLT_RECEIPT_VAULT = address(0x1f17523b147CcC2A2328c0F014f6d49c479ea063); - - /// @dev Wrapped token vault (ERC-4626, "wtPPLT") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x82f5BAEE1076334357a34A19E04f7c282D51cE47 - address constant PPLT_WRAPPED_TOKEN_VAULT = address(0x82f5BAEE1076334357a34A19E04f7c282D51cE47); - - // ========================================================================= - // tAMZN / wtAMZN — Amazon.com Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tAMZN. - /// https://basescan.org/address/0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721 - address constant AMZN_RECEIPT = address(0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721); - - /// @dev Receipt vault (ERC-20, "tAMZN") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd - address constant AMZN_RECEIPT_VAULT = address(0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd); - - /// @dev Wrapped token vault (ERC-4626, "wtAMZN") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x997baE3EC193a249596d3708C3fAB7C501Bb8a53 - address constant AMZN_WRAPPED_TOKEN_VAULT = address(0x997baE3EC193a249596d3708C3fAB7C501Bb8a53); - - // ========================================================================= - // tBMNR / wtBMNR — Bitmine Immersion Technologies, Inc ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tBMNR. - /// https://basescan.org/address/0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc - address constant BMNR_RECEIPT = address(0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc); - - /// @dev Receipt vault (ERC-20, "tBMNR") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0xfBde45dF60249203b12148452fC77C3B5F811eB2 - address constant BMNR_RECEIPT_VAULT = address(0xfBde45dF60249203b12148452fC77C3B5F811eB2); - - /// @dev Wrapped token vault (ERC-4626, "wtBMNR") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x2512EC661f0bA089c275EA105E31bAD6FcFcf319 - address constant BMNR_WRAPPED_TOKEN_VAULT = address(0x2512EC661f0bA089c275EA105E31bAD6FcFcf319); - - // ========================================================================= - // tIBHG / wtIBHG — iShares iBonds 2027 Term High Yield and Income ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tIBHG. - /// https://basescan.org/address/0xE603De6450555cEf32be7e666eEd70fddDa13e1e - address constant IBHG_RECEIPT = address(0xE603De6450555cEf32be7e666eEd70fddDa13e1e); - - /// @dev Receipt vault (ERC-20, "tIBHG") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF - address constant IBHG_RECEIPT_VAULT = address(0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF); - - /// @dev Wrapped token vault (ERC-4626, "wtIBHG") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0xf73894603e92d6f91b1f156e98cca38fd1f78dbf - address constant IBHG_WRAPPED_TOKEN_VAULT = address(0xF73894603e92D6f91B1f156e98Cca38Fd1F78dBf); - - // ========================================================================= - // tSGOV / wtSGOV — iShares 0-3 Month Treasury Bond ETF ST0x - // ========================================================================= - - /// @dev Receipt (ERC-1155) for tSGOV. - /// https://basescan.org/address/0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111 - address constant SGOV_RECEIPT = address(0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111); - - /// @dev Receipt vault (ERC-20, "tSGOV") — the OffchainAssetReceiptVault instance. - /// https://basescan.org/address/0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612 - address constant SGOV_RECEIPT_VAULT = address(0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612); - - /// @dev Wrapped token vault (ERC-4626, "wtSGOV") — the StoxWrappedTokenVault instance. - /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 - address constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); - - /// @notice The single authoriser every production receipt vault is gated - /// by. Pinned as a first-class invariant (see - /// `LibTokenInvariants.assertUniformAuthoriser`, composed into - /// `LibSafeInvariants.assertAll`). - /// @dev Read from `authorizer()` on the live vaults on Base on 2026-05-29. - /// A vault reporting any other authoriser is gated by a different RBAC - /// contract than the rest of the system and trips the invariant. - address constant PROD_RECEIPT_VAULT_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); - - /// @notice Returns the 13 production receipt vault addresses on Base, in - /// the order they were deployed. Provided so consumers (e.g. invariant - /// assertions, migration scripts) can iterate without hardcoding the - /// list inline. - /// @return vaults The 13 production receipt vault addresses on Base. - function productionReceiptVaults() internal pure returns (address[] memory vaults) { - vaults = new address[](13); - vaults[0] = MSTR_RECEIPT_VAULT; - vaults[1] = TSLA_RECEIPT_VAULT; - vaults[2] = COIN_RECEIPT_VAULT; - vaults[3] = SPYM_RECEIPT_VAULT; - vaults[4] = SIVR_RECEIPT_VAULT; - vaults[5] = CRCL_RECEIPT_VAULT; - vaults[6] = NVDA_RECEIPT_VAULT; - vaults[7] = IAU_RECEIPT_VAULT; - vaults[8] = PPLT_RECEIPT_VAULT; - vaults[9] = AMZN_RECEIPT_VAULT; - vaults[10] = BMNR_RECEIPT_VAULT; - vaults[11] = IBHG_RECEIPT_VAULT; - vaults[12] = SGOV_RECEIPT_VAULT; - } -} diff --git a/src/lib/LibSafeInvariants.sol b/src/lib/LibSafeInvariants.sol index 5dc43e34..c909fe5a 100644 --- a/src/lib/LibSafeInvariants.sol +++ b/src/lib/LibSafeInvariants.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; -import {LibProdSafes} from "./LibProdSafes.sol"; /// @notice The runtime codehash at the Safe's address does not match the /// pinned Safe v1.4.1 L2 proxy codehash. Signals either that the address has @@ -11,7 +10,7 @@ import {LibProdSafes} from "./LibProdSafes.sol"; /// different bytecode. /// @param safe The Safe address whose codehash was checked. /// @param expected The pinned codehash that was expected -/// (`LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH`). +/// (`SAFE_V1_4_1_L2_PROXY_CODEHASH`). /// @param actual The codehash returned by `extcodehash(safe)`. error SafeProxyCodehashMismatch(address safe, bytes32 expected, bytes32 actual); @@ -21,12 +20,12 @@ error SafeProxyCodehashMismatch(address safe, bytes32 expected, bytes32 actual); /// different singleton. /// @param safe The Safe proxy address that was inspected. /// @param expected The pinned singleton address -/// (`LibProdSafes.SAFE_V1_4_1_L2_SINGLETON`). +/// (`SAFE_V1_4_1_L2_SINGLETON`). /// @param actual The singleton address read from slot `0x0` of the proxy. error SafeSingletonMismatch(address safe, address expected, address actual); /// @notice The Safe singleton's runtime bytecode codehash does not match the -/// pinned `LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH`. Pinning the +/// pinned `SAFE_V1_4_1_L2_SINGLETON_CODEHASH`. Pinning the /// singleton address alone trusts the bytecode at that address; a swap (e.g. /// `SELFDESTRUCT` + recreate, or a delegatecall-time substitution on a /// forked test environment) could preserve the address while replacing the @@ -36,7 +35,7 @@ error SafeSingletonMismatch(address safe, address expected, address actual); /// @param safe The Safe proxy address that was inspected. /// @param singleton The singleton address read from slot `0x0` of the proxy. /// @param expected The pinned singleton codehash -/// (`LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH`). +/// (`SAFE_V1_4_1_L2_SINGLETON_CODEHASH`). /// @param actual The codehash observed at `singleton`. error SafeSingletonBytecodeMismatch(address safe, address singleton, bytes32 expected, bytes32 actual); @@ -69,7 +68,7 @@ error SafeUnexpectedGuard(address safe, address guard); /// used for introspection) so the pin is enforced as an invariant. /// @param safe The Safe address whose fallback handler slot was read. /// @param expected The pinned fallback handler address -/// (`LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER`). +/// (`SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER`). /// @param actual The fallback handler address read from the well-known /// fallback handler slot. error SafeFallbackHandlerMismatch(address safe, address expected, address actual); @@ -122,7 +121,7 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// and the two parameterised checks into a single call site. The pattern /// mirrors `StoxProdV2Test::checkAllV2OnChain`: a full-args helper that /// takes every expected value, and a no-arg default that fills in the -/// current-truth pins from `LibProdSafes`. Token-side invariants are +/// current-truth pins from `LibSafeInvariants`. Token-side invariants are /// composed alongside these by `LibInvariants.assertAll` for callers /// asserting the full production state; this lib is Safe-only by design /// so the file name doesn't mislead. @@ -139,6 +138,97 @@ error SafeThresholdMismatch(address safe, uint256 expected, uint256 actual); /// slot are explicit constants in `GuardManager`/`FallbackManager` chosen so /// they cannot collide with the owner/module/threshold linked-list slots. library LibSafeInvariants { + // ========================================================================= + // Safe v1.4.1 deployment manifest constants. Universal to every v1.4.1 L2 + // Safe; sourced from `safe-deployments` for chainId 8453 and cross-checked + // against the live ST0x production Safe. + // ========================================================================= + + /// @notice Safe v1.4.1 L2 singleton (master copy) address on Base. + /// Verified by reading proxy storage slot `0x0` of + /// `STOX_TOKEN_OWNER_SAFE` and matching against the + /// `safe-deployments` manifest. + address internal constant SAFE_V1_4_1_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; + + /// @notice Runtime codehash of a Safe v1.4.1 proxy on Base. Equal to + /// `extcodehash(STOX_TOKEN_OWNER_SAFE)` and to every other v1.4.1 L2 + /// proxy pointing at `SAFE_V1_4_1_L2_SINGLETON`. Pinning this codehash + /// guards against the Safe address being replaced by an EOA-controlled + /// contract or a fake proxy pointing at a malicious singleton. + bytes32 internal constant SAFE_V1_4_1_L2_PROXY_CODEHASH = + 0xb89c1b3bdf2cf8827818646bce9a8f6e372885f8c55e5c07acbd307cb133b000; + + /// @notice Expected `VERSION()` string from a Safe v1.4.1 singleton. + string internal constant SAFE_V1_4_1_VERSION = "1.4.1"; + + /// @notice Runtime codehash of the Safe v1.4.1 L2 singleton bytecode at + /// `SAFE_V1_4_1_L2_SINGLETON`. Pinning this guards against an attacker + /// who replaces the bytecode at the singleton address (e.g. via + /// `SELFDESTRUCT` + re-create) while preserving the proxy codehash. + /// Without this pin, every implementation-backed accessor on the Safe + /// (`VERSION()`, `getOwners()`, `getThreshold()`, etc.) is mediated by + /// untrusted code at the singleton address. Asserting this codehash + /// before any of those reads closes that gap. + /// @dev Computed via `keccak256(eth_getCode(SAFE_V1_4_1_L2_SINGLETON))` + /// on Base on 2026-05-20. + bytes32 internal constant SAFE_V1_4_1_L2_SINGLETON_CODEHASH = + 0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff; + + /// @notice CompatibilityFallbackHandler v1.4.1 address on Base. Verified + /// against the live Safe's fallback handler storage slot. Pinned so a + /// swapped-in malicious handler that shadows view selectors via + /// fallback can be detected by `assertImmutableInvariants`. + /// @dev Source: github.com/safe-global/safe-deployments + /// `src/assets/v1.4.1/compatibility_fallback_handler.json` (chainId + /// 8453 entry). Cross-checked on Base on 2026-05-20. + address internal constant SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER = 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99; + + // ========================================================================= + // ST0x token-owner Safe pins. Current-state invariants for the specific + // Safe at `STOX_TOKEN_OWNER_SAFE`; updated when the live state changes + // (e.g. the threshold migration bumps `STOX_TOKEN_OWNER_SAFE_THRESHOLD` + // from `1` to `3` in the same PR that records the post-execution state). + // ========================================================================= + + /// @notice The Safe that owns every ST0x receipt vault on Base. Subject + /// of the threshold migration (1 -> 3, against the post-rotation + /// 6-owner roster). + /// https://basescan.org/address/0xe70d821f3462A074E63b42D0aac6523faAe1D611 + address internal constant STOX_TOKEN_OWNER_SAFE = 0xe70d821f3462a074e63b42d0AaC6523faAe1d611; + + /// @notice The current expected threshold for `STOX_TOKEN_OWNER_SAFE`. + /// Updated by the threshold-migration PR family once live execution + /// lands: scripts and the post-migration pin both treat this constant + /// as the canonical current truth, so the value bumps from `1` to `3` + /// in the same PR that records the live post-execution state. + uint256 internal constant STOX_TOKEN_OWNER_SAFE_THRESHOLD = 1; + + /// @notice Owner #1 of `STOX_TOKEN_OWNER_SAFE`. Order matches + /// `getOwners()` (Safe-internal linked-list order) against the + /// post-rotation roster: `getOwners()` returns owners newest-first, + /// so the last signer to be added via `addOwnerWithThreshold` appears + /// at slot 0. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_1 = 0x4746095B1Ea1A84446d34448f44e74D3d51f92F2; + + /// @notice Owner #2 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_2 = 0xceC2cb8B8EE4000FFA3F8a7f8E0Fa0A3E3DAb72d; + + /// @notice Owner #3 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_3 = 0x8D5901d8aE48101B59400235ad8614A2e0510466; + + /// @notice Owner #4 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_4 = 0xC1C89b7f5448F447d59f920456A9610f6b2544bC; + + /// @notice Owner #5 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_5 = 0xAB92b327c97A6E7461cBd76E2a789E5e106FF87e; + + /// @notice Owner #6 of `STOX_TOKEN_OWNER_SAFE`. + address internal constant STOX_TOKEN_OWNER_SAFE_OWNER_6 = 0x5CCd3cE683b66ff271DDB8915fF528b8fcFa23c2; + + // ========================================================================= + // Storage layout constants for paginated / direct slot reads. + // ========================================================================= + /// @notice Storage slot at which Safe v1.4.1 stores the transaction /// guard address. Equal to /// `keccak256("guard_manager.guard.address")`. A non-zero value here @@ -197,16 +287,16 @@ library LibSafeInvariants { assembly ("memory-safe") { actualCodehash := extcodehash(safeAddr) } - if (actualCodehash != LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH) { - revert SafeProxyCodehashMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH, actualCodehash); + if (actualCodehash != SAFE_V1_4_1_L2_PROXY_CODEHASH) { + revert SafeProxyCodehashMismatch(safeAddr, SAFE_V1_4_1_L2_PROXY_CODEHASH, actualCodehash); } // Slot 0 of a Safe proxy holds the singleton (master copy) address. // Read it raw via `getStorageAt` rather than going through any // accessor so a malicious fallback can't shadow the result. address actualSingleton = readSafeStorageAddress(safe, 0); - if (actualSingleton != LibProdSafes.SAFE_V1_4_1_L2_SINGLETON) { - revert SafeSingletonMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, actualSingleton); + if (actualSingleton != SAFE_V1_4_1_L2_SINGLETON) { + revert SafeSingletonMismatch(safeAddr, SAFE_V1_4_1_L2_SINGLETON, actualSingleton); } // Address pin alone trusts whatever code lives at the singleton @@ -219,15 +309,15 @@ library LibSafeInvariants { assembly ("memory-safe") { actualSingletonCodehash := extcodehash(actualSingleton) } - if (actualSingletonCodehash != LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH) { + if (actualSingletonCodehash != SAFE_V1_4_1_L2_SINGLETON_CODEHASH) { revert SafeSingletonBytecodeMismatch( - safeAddr, actualSingleton, LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH, actualSingletonCodehash + safeAddr, actualSingleton, SAFE_V1_4_1_L2_SINGLETON_CODEHASH, actualSingletonCodehash ); } string memory actualVersion = safe.VERSION(); - if (keccak256(bytes(actualVersion)) != keccak256(bytes(LibProdSafes.SAFE_V1_4_1_VERSION))) { - revert SafeVersionMismatch(safeAddr, LibProdSafes.SAFE_V1_4_1_VERSION, actualVersion); + if (keccak256(bytes(actualVersion)) != keccak256(bytes(SAFE_V1_4_1_VERSION))) { + revert SafeVersionMismatch(safeAddr, SAFE_V1_4_1_VERSION, actualVersion); } // Page size 10 is sufficient: any non-zero module count trips the @@ -247,9 +337,9 @@ library LibSafeInvariants { } address actualFallbackHandler = readSafeStorageAddress(safe, uint256(SAFE_FALLBACK_HANDLER_STORAGE_SLOT)); - if (actualFallbackHandler != LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER) { + if (actualFallbackHandler != SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER) { revert SafeFallbackHandlerMismatch( - safeAddr, LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, actualFallbackHandler + safeAddr, SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, actualFallbackHandler ); } } @@ -301,7 +391,7 @@ library LibSafeInvariants { } /// @notice Full-args Safe-side invariant bundle. Use when you want to - /// override the expected threshold or owner set from the `LibProdSafes` + /// override the expected threshold or owner set from the `LibSafeInvariants` /// current-truth pins — typically only when running a script that /// intentionally changes one of those (post-state assertion). /// @dev Composes the Safe-side invariants only: immutable Safe @@ -317,15 +407,15 @@ library LibSafeInvariants { /// deliberately-changed expectation. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. - /// @param expectedOwners The expected owner set in `getOwners()` order. - function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { + /// @param expectedOwnerSet The expected owner set in `getOwners()` order. + function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwnerSet) internal view { assertImmutableInvariants(safe); - assertOwnerSet(safe, expectedOwners); + assertOwnerSet(safe, expectedOwnerSet); assertThreshold(safe, expectedThreshold); } /// @notice No-arg Safe-side invariant bundle that fills in the - /// `LibProdSafes`-pinned current-truth defaults: the threshold from + /// `LibSafeInvariants`-pinned current-truth defaults: the threshold from /// `STOX_TOKEN_OWNER_SAFE_THRESHOLD` and the owner set from /// `expectedOwners()`. Pre-flight at the start of every script and /// fork test that runs against the production Safe; if this passes @@ -336,6 +426,27 @@ library LibSafeInvariants { /// re-check after it has simulated `changeThreshold`). /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { - assertAll(safe, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, LibProdSafes.expectedOwners()); + assertAll(safe, STOX_TOKEN_OWNER_SAFE_THRESHOLD, expectedOwners()); + } + + /// @notice Returns the expected owner set for `STOX_TOKEN_OWNER_SAFE` in + /// the exact order returned by `getOwners()` against an unpinned Base + /// head fork (the live-state pin lives in + /// `StoxProdV2.t.sol::testProdDeployBaseV2`, which selects head rather + /// than pinning to a historical block so the next CI run catches any + /// further drift). Provided as a helper because Solidity 0.8 cannot + /// express a file-scope `constant address[]` and declaring the array + /// as `immutable` is contract-scoped only. + /// @return The six owners of the ST0x token-owner Safe in + /// `getOwners()` order. + function expectedOwners() internal pure returns (address[] memory) { + address[] memory owners = new address[](6); + owners[0] = STOX_TOKEN_OWNER_SAFE_OWNER_1; + owners[1] = STOX_TOKEN_OWNER_SAFE_OWNER_2; + owners[2] = STOX_TOKEN_OWNER_SAFE_OWNER_3; + owners[3] = STOX_TOKEN_OWNER_SAFE_OWNER_4; + owners[4] = STOX_TOKEN_OWNER_SAFE_OWNER_5; + owners[5] = STOX_TOKEN_OWNER_SAFE_OWNER_6; + return owners; } } diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index 06b64938..04599e97 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -2,8 +2,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {LibProdTokensBase} from "./LibProdTokensBase.sol"; - /// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. /// Every production receipt vault exposes `owner()`; this library only /// needs the getter, not the transfer/renounce mutators. Declared inline @@ -46,7 +44,7 @@ error ReceiptVaultAuthoriserMismatch(address vault, address expected, address ac /// @title LibTokenInvariants /// @notice Reusable token-side uniformity invariants for the ST0x /// production receipt vaults on Base. Each assertion iterates the vault -/// list emitted by `LibProdTokensBase.productionReceiptVaults` and either +/// list emitted by `productionReceiptVaults` and either /// returns silently when the invariant holds against the live chain state /// or reverts with a typed error that pinpoints the offending vault. /// @dev These are token-side prod invariants: a receipt vault's owner and @@ -56,8 +54,150 @@ error ReceiptVaultAuthoriserMismatch(address vault, address expected, address ac /// production state get both. Individual asserts are also callable /// standalone for focused drift detection. library LibTokenInvariants { + // ========================================================================= + // Production token instance addresses on Base. Each token set is a + // beacon proxy triple deployed via V1 OffchainAssetReceiptVaultBeaconSetDeployer + // + V1 StoxWrappedTokenVaultBeaconSetDeployer: a receipt (ERC-1155), a + // receipt vault (ERC-20), and a wrapped token vault (ERC-4626). + // ========================================================================= + + // ---- tMSTR / wtMSTR — MicroStrategy Incorporated ST0x ---- + /// https://basescan.org/address/0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC + address internal constant MSTR_RECEIPT = address(0x1c1fEF6f7b8e576219554b1d11c8aF29D00C0cEC); + /// https://basescan.org/address/0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE + address internal constant MSTR_RECEIPT_VAULT = address(0x013b782F402d61aa1004CCA95b9f5Bb402c9d5FE); + /// https://basescan.org/address/0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2 + address internal constant MSTR_WRAPPED_TOKEN_VAULT = address(0xFF05E1bD696900dc6A52CA35Ca61Bb1024eDa8e2); + + // ---- tTSLA / wtTSLA — Tesla Inc ST0x ---- + /// https://basescan.org/address/0x660923230fAA859622711a5fC80f532dd588b125 + address internal constant TSLA_RECEIPT = address(0x660923230fAA859622711a5fC80f532dd588b125); + /// https://basescan.org/address/0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0 + address internal constant TSLA_RECEIPT_VAULT = address(0x4E169cD2Ab4f82640a8c65C68feD55863866fDB0); + /// https://basescan.org/address/0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D + address internal constant TSLA_WRAPPED_TOKEN_VAULT = address(0x219A8d384a10BF19b9f24cB5cC53F79Dd0e5A03D); + + // ---- tCOIN / wtCOIN — Coinbase Global Inc ST0x ---- + /// https://basescan.org/address/0xBA1B8836A5510815e96103F067715b7CCC7c2E0E + address internal constant COIN_RECEIPT = address(0xBA1B8836A5510815e96103F067715b7CCC7c2E0E); + /// https://basescan.org/address/0x626757e6F50675D17fcAd312E82f989aE7A23d38 + address internal constant COIN_RECEIPT_VAULT = address(0x626757e6F50675D17fcAd312E82f989aE7A23d38); + /// https://basescan.org/address/0x5cDa0E1CA4ce2af96315f7F8963C85399c172204 + address internal constant COIN_WRAPPED_TOKEN_VAULT = address(0x5cDa0E1CA4ce2af96315f7F8963C85399c172204); + + // ---- tSPYM / wtSPYM — State Street SPDR Portfolio S&P 500 ETF ST0x ---- + /// https://basescan.org/address/0x957056dD6e2E594742E36675e8AA5A567163E5bd + address internal constant SPYM_RECEIPT = address(0x957056dD6e2E594742E36675e8AA5A567163E5bd); + /// https://basescan.org/address/0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb + address internal constant SPYM_RECEIPT_VAULT = address(0x8Fdf41116F755771Bfe0747D5F8C3711D5DEbfBb); + /// https://basescan.org/address/0x31C2C14134e6E3B7ef9478297F199331133Fc2d8 + address internal constant SPYM_WRAPPED_TOKEN_VAULT = address(0x31C2C14134e6E3B7ef9478297F199331133Fc2d8); + + // ---- tSIVR / wtSIVR — abrdn Physical Silver Shares ETF ST0x ---- + /// https://basescan.org/address/0x053F52109a3439b4F292056D2DceC0486B544e82 + address internal constant SIVR_RECEIPT = address(0x053F52109a3439b4F292056D2DceC0486B544e82); + /// https://basescan.org/address/0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8 + address internal constant SIVR_RECEIPT_VAULT = address(0x58cE5024B89B4f73C27814C0f0aBbEa331C99Be8); + /// https://basescan.org/address/0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F + address internal constant SIVR_WRAPPED_TOKEN_VAULT = address(0xEB7F3E4093C9d68253b6104FbbfF561F3eC0442F); + + // ---- tCRCL / wtCRCL — Circle Internet Group Inc ST0x ---- + /// https://basescan.org/address/0xd508B97975fBE04E62bFf18959549b046bD8FA78 + address internal constant CRCL_RECEIPT = address(0xd508B97975fBE04E62bFf18959549b046bD8FA78); + /// https://basescan.org/address/0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52 + address internal constant CRCL_RECEIPT_VAULT = address(0x38Eb797892ED71Da69bDc27A456A7c83Ff813b52); + /// https://basescan.org/address/0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf + address internal constant CRCL_WRAPPED_TOKEN_VAULT = address(0x8AFba81DEc38DE0A18E2Df5E1967a7493651eebf); + + // ---- tNVDA / wtNVDA — NVIDIA Corporation ST0x ---- + /// https://basescan.org/address/0x8Dd4c6f08E446075879310AFae8167CC4DE2f805 + address internal constant NVDA_RECEIPT = address(0x8Dd4c6f08E446075879310AFae8167CC4DE2f805); + /// https://basescan.org/address/0x7271A3C91Bb6070eD09333B84a815949D4f16d14 + address internal constant NVDA_RECEIPT_VAULT = address(0x7271A3C91Bb6070eD09333B84a815949D4f16d14); + /// https://basescan.org/address/0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7 + address internal constant NVDA_WRAPPED_TOKEN_VAULT = address(0xFb5B41acdbA20a3230F84BE995173CFb98b8D6E7); + + // ---- tIAU / wtIAU — iShares Gold Trust ST0x ---- + /// https://basescan.org/address/0x9E128159ff53Ce113df52D760C032DD65DDb0E64 + address internal constant IAU_RECEIPT = address(0x9E128159ff53Ce113df52D760C032DD65DDb0E64); + /// https://basescan.org/address/0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF + address internal constant IAU_RECEIPT_VAULT = address(0x9A507314EA2a6C5686C0D07BfecB764dCF324dFF); + /// https://basescan.org/address/0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8 + address internal constant IAU_WRAPPED_TOKEN_VAULT = address(0x1E46d7eFef64A833AFB1CD49299a7AD5B439f4d8); + + // ---- tPPLT / wtPPLT — abrdn Physical Platinum Shares ETF ST0x ---- + /// https://basescan.org/address/0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6 + address internal constant PPLT_RECEIPT = address(0x61b5a0424cD3adcd3b312619fC58B6fCeFA1ECb6); + /// https://basescan.org/address/0x1f17523b147CcC2A2328c0F014f6d49c479ea063 + address internal constant PPLT_RECEIPT_VAULT = address(0x1f17523b147CcC2A2328c0F014f6d49c479ea063); + /// https://basescan.org/address/0x82f5BAEE1076334357a34A19E04f7c282D51cE47 + address internal constant PPLT_WRAPPED_TOKEN_VAULT = address(0x82f5BAEE1076334357a34A19E04f7c282D51cE47); + + // ---- tAMZN / wtAMZN — Amazon.com Inc ST0x ---- + /// https://basescan.org/address/0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721 + address internal constant AMZN_RECEIPT = address(0x3C4895df971e5c1fDCa81bF74aDb8eeE94F24721); + /// https://basescan.org/address/0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd + address internal constant AMZN_RECEIPT_VAULT = address(0x466CB2e46Fa1AfC0AB5e22274B34d0391db18eFd); + /// https://basescan.org/address/0x997baE3EC193a249596d3708C3fAB7C501Bb8a53 + address internal constant AMZN_WRAPPED_TOKEN_VAULT = address(0x997baE3EC193a249596d3708C3fAB7C501Bb8a53); + + // ---- tBMNR / wtBMNR — Bitmine Immersion Technologies, Inc ST0x ---- + /// https://basescan.org/address/0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc + address internal constant BMNR_RECEIPT = address(0x67aeAFD8c274F62933fEc34E8c0724189AaD01fc); + /// https://basescan.org/address/0xfBde45dF60249203b12148452fC77C3B5F811eB2 + address internal constant BMNR_RECEIPT_VAULT = address(0xfBde45dF60249203b12148452fC77C3B5F811eB2); + /// https://basescan.org/address/0x2512EC661f0bA089c275EA105E31bAD6FcFcf319 + address internal constant BMNR_WRAPPED_TOKEN_VAULT = address(0x2512EC661f0bA089c275EA105E31bAD6FcFcf319); + + // ---- tIBHG / wtIBHG — iShares iBonds 2027 Term High Yield and Income ETF ST0x ---- + /// https://basescan.org/address/0xE603De6450555cEf32be7e666eEd70fddDa13e1e + address internal constant IBHG_RECEIPT = address(0xE603De6450555cEf32be7e666eEd70fddDa13e1e); + /// https://basescan.org/address/0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF + address internal constant IBHG_RECEIPT_VAULT = address(0x3c0F093aa1eD511910279b2C8d56eF5c96f1a6cF); + /// https://basescan.org/address/0xf73894603e92d6f91b1f156e98cca38fd1f78dbf + address internal constant IBHG_WRAPPED_TOKEN_VAULT = address(0xF73894603e92D6f91B1f156e98Cca38Fd1F78dBf); + + // ---- tSGOV / wtSGOV — iShares 0-3 Month Treasury Bond ETF ST0x ---- + /// https://basescan.org/address/0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111 + address internal constant SGOV_RECEIPT = address(0x5c28F1Dd98dC2D61F289545c3be85cafdb4cB111); + /// https://basescan.org/address/0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612 + address internal constant SGOV_RECEIPT_VAULT = address(0xc941C1506B7555Ba8C506Fb6c9b9CC259902d612); + /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 + address internal constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); + + /// @notice The single authoriser every production receipt vault is gated + /// by, as a token-side invariant. Pinned here as the expected value for + /// `assertUniformAuthoriser`; updated post-swap when the receipt vaults + /// are rewired onto a new authoriser clone. + /// @dev Read from `authorizer()` on the live vaults on Base. A vault + /// reporting any other authoriser is gated by a different RBAC contract + /// than the rest of the system and trips the invariant. + address internal constant STOX_PROD_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); + + /// @notice Returns the 13 production receipt vault addresses on Base, in + /// the order they were deployed. Provided so consumers (e.g. invariant + /// assertions, migration scripts) can iterate without hardcoding the + /// list inline. + /// @return vaults The 13 production receipt vault addresses on Base. + function productionReceiptVaults() internal pure returns (address[] memory vaults) { + vaults = new address[](13); + vaults[0] = MSTR_RECEIPT_VAULT; + vaults[1] = TSLA_RECEIPT_VAULT; + vaults[2] = COIN_RECEIPT_VAULT; + vaults[3] = SPYM_RECEIPT_VAULT; + vaults[4] = SIVR_RECEIPT_VAULT; + vaults[5] = CRCL_RECEIPT_VAULT; + vaults[6] = NVDA_RECEIPT_VAULT; + vaults[7] = IAU_RECEIPT_VAULT; + vaults[8] = PPLT_RECEIPT_VAULT; + vaults[9] = AMZN_RECEIPT_VAULT; + vaults[10] = BMNR_RECEIPT_VAULT; + vaults[11] = IBHG_RECEIPT_VAULT; + vaults[12] = SGOV_RECEIPT_VAULT; + } + /// @notice Assert that every production receipt vault reports the same - /// `owner()`. Iterates `LibProdTokensBase.productionReceiptVaults` and + /// `owner()`. Iterates `productionReceiptVaults` and /// reverts with `ReceiptVaultOwnerMismatch` on the first vault whose /// `owner()` diverges from `expectedOwner`, surfacing the offending /// vault. @@ -69,7 +209,7 @@ library LibTokenInvariants { /// @param expectedOwner The address every production receipt vault is /// expected to report as `owner()`. function assertUniformOwnership(address expectedOwner) internal view { - address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); + address[] memory vaults = productionReceiptVaults(); for (uint256 i = 0; i < vaults.length; i++) { address actualOwner = IOwnable(vaults[i]).owner(); if (actualOwner != expectedOwner) { @@ -79,7 +219,7 @@ library LibTokenInvariants { } /// @notice Assert that every production receipt vault reports the same - /// authoriser. Iterates `LibProdTokensBase.productionReceiptVaults` and + /// authoriser. Iterates `productionReceiptVaults` and /// reverts with `ReceiptVaultAuthoriserMismatch` on the first vault whose /// `authorizer()` diverges from `expected`, surfacing the offending vault. /// @dev A divergent authoriser means a token is gated by a different RBAC @@ -89,7 +229,7 @@ library LibTokenInvariants { /// @param expected The authoriser address every production receipt vault /// is expected to share. function assertUniformAuthoriser(address expected) internal view { - address[] memory vaults = LibProdTokensBase.productionReceiptVaults(); + address[] memory vaults = productionReceiptVaults(); for (uint256 i = 0; i < vaults.length; i++) { address actual = IAuthorisable(vaults[i]).authorizer(); if (actual != expected) { @@ -99,19 +239,23 @@ library LibTokenInvariants { } /// @notice Full token-side invariant bundle: every production receipt - /// vault reports the Safe as its `owner()` AND the pinned production + /// vault reports the supplied Safe as its `owner()` AND the supplied /// authoriser as its `authorizer()`. Pre-flight / post-state hook for /// any script touching the production receipt vault set; consumers - /// asserting the full production state (Safe + token) compose this - /// alongside `LibSafeInvariants.assertAll` via `LibInvariants.assertAll`. + /// asserting the full production state (Safe + token + authoriser) + /// compose this alongside `LibSafeInvariants.assertAll` and + /// `LibAuthoriserInvariants.assertAll` via `LibInvariants.assertAll`. /// @dev Both legs run last in the composed bundle because each is - /// `O(13)` external calls and only meaningful once the Safe itself has - /// been validated. + /// `O(13)` external calls and only meaningful once the Safe itself + /// has been validated. The authoriser is parameterised rather than + /// hardcoded so this lib stays free of cross-facet dependencies; the + /// orchestrator supplies the pinned address. /// @param safe The Safe address every production receipt vault is - /// expected to report as `owner()`. The authoriser is sourced from - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. - function assertAll(address safe) internal view { + /// expected to report as `owner()`. + /// @param expectedAuthoriser The authoriser address every production + /// receipt vault is expected to report as `authorizer()`. + function assertAll(address safe, address expectedAuthoriser) internal view { assertUniformOwnership(safe); - assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); + assertUniformAuthoriser(expectedAuthoriser); } } diff --git a/test/script/MigrateMultisigThresholdTest.t.sol b/test/script/MigrateMultisigThresholdTest.t.sol index bbf4880c..c97c2090 100644 --- a/test/script/MigrateMultisigThresholdTest.t.sol +++ b/test/script/MigrateMultisigThresholdTest.t.sol @@ -9,11 +9,11 @@ import { VerifyExpectedSingleTx } from "../../script/MigrateMultisigThreshold.s.sol"; import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol"; -import {LibProdSafes} from "../../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol"; import {LibSafeOps, SafeTx} from "../../src/lib/LibSafeOps.sol"; import {LibSafeInvariants, SafeThresholdMismatch} from "../../src/lib/LibSafeInvariants.sol"; import {IOwnable, ReceiptVaultOwnerMismatch} from "../../src/lib/LibTokenInvariants.sol"; -import {LibProdTokensBase} from "../../src/lib/LibProdTokensBase.sol"; +import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// @title MigrateMultisigThresholdTest @@ -35,7 +35,7 @@ contract MigrateMultisigThresholdTest is Test { function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); script = new MigrateMultisigThreshold(); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); } /// @notice `run()` dry-run completes against the live pre-state, @@ -69,7 +69,7 @@ contract MigrateMultisigThresholdTest is Test { // of an internal revert. assertEq( safe.getThreshold(), - LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "n+1 reversibility check rolled threshold back to pre-migration value" ); } @@ -113,10 +113,10 @@ contract MigrateMultisigThresholdTest is Test { function testRunRejectsVaultOwnershipDrift() external { selectBaseFork(); address rogueOwner = address(0xBADC0DE); - // Victim address sourced from `LibProdTokensBase` — the canonical + // Victim address sourced from `LibTokenInvariants` — the canonical // list of production receipt vaults. Any vault from the list // would do; MSTR is the first entry. - address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; + 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)); diff --git a/test/src/concrete/deploy/StoxProdV2.t.sol b/test/src/concrete/deploy/StoxProdV2.t.sol index 40e771f9..a0f59ffd 100644 --- a/test/src/concrete/deploy/StoxProdV2.t.sol +++ b/test/src/concrete/deploy/StoxProdV2.t.sol @@ -5,7 +5,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibProdDeployV2} from "../../../../src/lib/LibProdDeployV2.sol"; import {LibProdDeployV2BaseOverrides} from "../../../../src/lib/LibProdDeployV2BaseOverrides.sol"; -import {LibProdSafes} from "../../../../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../../../../src/lib/LibSafeInvariants.sol"; import {LibInvariants} from "../../../../src/lib/LibInvariants.sol"; import {IGnosisSafe} from "../../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; @@ -123,12 +123,12 @@ contract StoxProdV2Test is Test { /// Per-Safe invariant bundle for the ST0x token-owner Safe on Base. /// Calls `LibInvariants.assertAll` against the production Safe - /// address pinned in `LibProdSafes` — composes the Safe-side and + /// address pinned in `LibSafeInvariants` — composes the Safe-side and /// token-side invariants in one call. The Safe is Base-only (no Safe /// on Arbitrum / Base Sepolia / Flare / Polygon for ST0x ops), so /// this helper is only invoked from `testProdDeployBaseV2`. function checkAllSafeBase() internal view { - LibInvariants.assertAll(IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE)); + LibInvariants.assertAll(IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE)); } /// All V2 contracts MUST be deployed on Arbitrum. diff --git a/test/src/lib/LibSafeInvariants.t.sol b/test/src/lib/LibSafeInvariants.t.sol index 100f9021..cf232dcc 100644 --- a/test/src/lib/LibSafeInvariants.t.sol +++ b/test/src/lib/LibSafeInvariants.t.sol @@ -5,7 +5,6 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibSafeInvariantsHarness} from "./LibSafeInvariantsHarness.sol"; -import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import { @@ -47,7 +46,7 @@ contract LibSafeInvariantsTest is Test { /// unpinned. Live drift detector; see contract-level rationale. function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); harness = new LibSafeInvariantsHarness(); } @@ -68,7 +67,7 @@ contract LibSafeInvariantsTest is Test { abi.encodeWithSelector( SafeProxyCodehashMismatch.selector, safeAddr, - LibProdSafes.SAFE_V1_4_1_L2_PROXY_CODEHASH, + LibSafeInvariants.SAFE_V1_4_1_L2_PROXY_CODEHASH, mutatedCodehash ) ); @@ -88,7 +87,7 @@ contract LibSafeInvariantsTest is Test { ); vm.expectRevert( abi.encodeWithSelector( - SafeSingletonMismatch.selector, address(safe), LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, impostor + SafeSingletonMismatch.selector, address(safe), LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, impostor ) ); harness.callAssertImmutableInvariants(safe); @@ -108,19 +107,19 @@ contract LibSafeInvariantsTest is Test { function testInvertedSingletonBytecodeMismatch() external { selectBaseFork(); bytes memory bogusCode = hex"60016000526001601ff3"; - vm.etch(LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, bogusCode); + vm.etch(LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, bogusCode); vm.mockCall( address(safe), abi.encodeWithSelector(IGnosisSafe.getStorageAt.selector, uint256(0), uint256(1)), - abi.encode(abi.encodePacked(bytes32(uint256(uint160(LibProdSafes.SAFE_V1_4_1_L2_SINGLETON))))) + abi.encode(abi.encodePacked(bytes32(uint256(uint160(LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON))))) ); - bytes32 expected = LibProdSafes.SAFE_V1_4_1_L2_SINGLETON_CODEHASH; + bytes32 expected = LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON_CODEHASH; bytes32 actual = keccak256(bogusCode); vm.expectRevert( abi.encodeWithSelector( SafeSingletonBytecodeMismatch.selector, address(safe), - LibProdSafes.SAFE_V1_4_1_L2_SINGLETON, + LibSafeInvariants.SAFE_V1_4_1_L2_SINGLETON, expected, actual ) @@ -136,7 +135,9 @@ contract LibSafeInvariantsTest is Test { string memory bogus = "9.9.9"; vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.VERSION.selector), abi.encode(bogus)); vm.expectRevert( - abi.encodeWithSelector(SafeVersionMismatch.selector, address(safe), LibProdSafes.SAFE_V1_4_1_VERSION, bogus) + abi.encodeWithSelector( + SafeVersionMismatch.selector, address(safe), LibSafeInvariants.SAFE_V1_4_1_VERSION, bogus + ) ); harness.callAssertImmutableInvariants(safe); } @@ -196,7 +197,7 @@ contract LibSafeInvariantsTest is Test { abi.encodeWithSelector( SafeFallbackHandlerMismatch.selector, address(safe), - LibProdSafes.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, + LibSafeInvariants.SAFE_V1_4_1_COMPATIBILITY_FALLBACK_HANDLER, impostor ) ); @@ -208,9 +209,9 @@ contract LibSafeInvariantsTest is Test { function testInvertedOwnerCountMismatch() external { selectBaseFork(); address[] memory truncated = new address[](3); - truncated[0] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_1; - truncated[1] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2; - truncated[2] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_3; + truncated[0] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_1; + truncated[1] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2; + truncated[2] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_3; vm.expectRevert(abi.encodeWithSelector(SafeOwnerCountMismatch.selector, address(safe), uint256(3), uint256(6))); harness.callAssertOwnerSet(safe, truncated); } @@ -220,7 +221,7 @@ contract LibSafeInvariantsTest is Test { /// index 1. function testInvertedOwnerMismatch() external { selectBaseFork(); - address[] memory swapped = LibProdSafes.expectedOwners(); + address[] memory swapped = LibSafeInvariants.expectedOwners(); address impostor = address(0xC0FFEE); swapped[1] = impostor; vm.expectRevert( @@ -229,7 +230,7 @@ contract LibSafeInvariantsTest is Test { address(safe), uint256(1), impostor, - LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2 + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2 ) ); harness.callAssertOwnerSet(safe, swapped); @@ -248,13 +249,16 @@ contract LibSafeInvariantsTest is Test { /// pinned current truth. Mocks `getThreshold()` to `5` and asserts the /// bundle surfaces the threshold error rather than passing silently. /// This is the load-bearing test for the no-arg overload's defaulting - /// to `LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD`. + /// to `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD`. function testInvertedAssertAllDefaultsThresholdDrift() external { selectBaseFork(); vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getThreshold.selector), abi.encode(uint256(5))); vm.expectRevert( abi.encodeWithSelector( - SafeThresholdMismatch.selector, address(safe), LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, uint256(5) + SafeThresholdMismatch.selector, + address(safe), + LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, + uint256(5) ) ); harness.callAssertAllDefaults(safe); @@ -269,6 +273,6 @@ contract LibSafeInvariantsTest is Test { function testInvertedAssertAllFullArgsThresholdMismatch() external { selectBaseFork(); vm.expectRevert(abi.encodeWithSelector(SafeThresholdMismatch.selector, address(safe), uint256(4), uint256(1))); - harness.callAssertAll(safe, 4, LibProdSafes.expectedOwners()); + harness.callAssertAll(safe, 4, LibSafeInvariants.expectedOwners()); } } diff --git a/test/src/lib/LibSafeOps.t.sol b/test/src/lib/LibSafeOps.t.sol index 604af265..b83803b3 100644 --- a/test/src/lib/LibSafeOps.t.sol +++ b/test/src/lib/LibSafeOps.t.sol @@ -4,7 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeOps, SafeTx, TxBuilderJsonNoTransactions} from "../../../src/lib/LibSafeOps.sol"; -import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; import {CallerRecorder} from "./CallerRecorder.sol"; @@ -22,12 +22,12 @@ contract LibSafeOpsTest is Test { IGnosisSafe internal safe; /// @notice Selects the Base fork at chain head — deliberately unpinned. - /// Mirrors `LibProdSafes.t.sol::selectBaseFork` and + /// Mirrors `LibSafeInvariants.t.sol::selectBaseFork` and /// `StoxProdV2.t.sol::testProdDeployBaseV2`: any drift in the live Safe /// surfaces immediately on the next CI run. function selectBaseFork() internal { vm.createSelectFork(LibRainDeploy.BASE); - safe = IGnosisSafe(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); } /// @notice Build a single-tx bundle that changes the Safe's threshold @@ -63,7 +63,7 @@ contract LibSafeOpsTest is Test { selectBaseFork(); uint256 nonceBefore = safe.nonce(); uint256 thresholdBefore = safe.getThreshold(); - assertEq(thresholdBefore, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD); + assertEq(thresholdBefore, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD); SafeTx memory txn = _buildThresholdTx(); LibSafeOps.simulateSelfCall(safe, txn.data); @@ -175,7 +175,7 @@ contract LibSafeOpsTest is Test { function testSimulateNPlus1ReversalRoundTrip() external { selectBaseFork(); uint256 oldThreshold = safe.getThreshold(); - assertEq(oldThreshold, LibProdSafes.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "pre-state threshold pin"); + assertEq(oldThreshold, LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_THRESHOLD, "pre-state threshold pin"); // Simulate the forward state change the migration script makes. // After this prank-call the Safe is in the "post-migration" state @@ -202,8 +202,8 @@ contract LibSafeOpsTest is Test { // for `newThreshold = 3`. The require in `simulateNPlus1Reversal` // should fire before any prank/approve hit the Safe. address[] memory shortRoster = new address[](2); - shortRoster[0] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_1; - shortRoster[1] = LibProdSafes.STOX_TOKEN_OWNER_SAFE_OWNER_2; + shortRoster[0] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_1; + shortRoster[1] = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE_OWNER_2; vm.mockCall(address(safe), abi.encodeWithSelector(IGnosisSafe.getOwners.selector), abi.encode(shortRoster)); NPlus1Harness harness = new NPlus1Harness(); diff --git a/test/src/lib/LibProdTokensBase.t.sol b/test/src/lib/LibTokenInvariants.addresses.t.sol similarity index 92% rename from test/src/lib/LibProdTokensBase.t.sol rename to test/src/lib/LibTokenInvariants.addresses.t.sol index 611bbe7e..4480be7b 100644 --- a/test/src/lib/LibProdTokensBase.t.sol +++ b/test/src/lib/LibTokenInvariants.addresses.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; +import {LibTokenInvariants} from "../../../src/lib/LibTokenInvariants.sol"; import {LibProdDeployV1} from "../../../src/lib/LibProdDeployV1.sol"; import {LibTestProd} from "../../lib/LibTestProd.sol"; import {IERC20Metadata} from "@openzeppelin-contracts-5.6.1/token/ERC20/extensions/IERC20Metadata.sol"; @@ -25,9 +25,9 @@ import {IExtrospectV1} from "rain-extrospection-0.1.1/src/interface/IExtrospectV import {EXTROSPECT_ZOLTU_ADDRESS_V1} from "rain-extrospection-0.1.1/src/concrete/Extrospect.sol"; import {IBeacon} from "rain-extrospection-0.1.1/src/interface/IBeacon.sol"; -/// @title LibProdTokensBaseTest +/// @title LibTokenInvariantsAddressesTest /// @notice Fork tests verifying production token instances on Base. -contract LibProdTokensBaseTest is Test { +contract LibTokenInvariantsAddressesTest is Test { /// Read the EIP-1967 beacon address from a proxy contract. function beaconOf(address proxy) internal view returns (address) { return address(uint160(uint256(vm.load(proxy, ERC1967_BEACON_SLOT)))); @@ -478,7 +478,7 @@ contract LibProdTokensBaseTest is Test { // All wrapped vault proxies share a single beacon. Read it from // any wrapped proxy (MSTR is arbitrary) and assert the constant. assertEq( - beaconOf(LibProdTokensBase.MSTR_WRAPPED_TOKEN_VAULT), + beaconOf(LibTokenInvariants.MSTR_WRAPPED_TOKEN_VAULT), LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, "wrapped vault beacon read from MSTR proxy slot drifted" ); @@ -522,9 +522,9 @@ contract LibProdTokensBaseTest is Test { function testMstrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.MSTR_RECEIPT, - LibProdTokensBase.MSTR_RECEIPT_VAULT, - LibProdTokensBase.MSTR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.MSTR_RECEIPT, + LibTokenInvariants.MSTR_RECEIPT_VAULT, + LibTokenInvariants.MSTR_WRAPPED_TOKEN_VAULT, "tMSTR", "wtMSTR" ); @@ -533,9 +533,9 @@ contract LibProdTokensBaseTest is Test { function testTslaTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.TSLA_RECEIPT, - LibProdTokensBase.TSLA_RECEIPT_VAULT, - LibProdTokensBase.TSLA_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.TSLA_RECEIPT, + LibTokenInvariants.TSLA_RECEIPT_VAULT, + LibTokenInvariants.TSLA_WRAPPED_TOKEN_VAULT, "tTSLA", "wtTSLA" ); @@ -544,9 +544,9 @@ contract LibProdTokensBaseTest is Test { function testCoinTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.COIN_RECEIPT, - LibProdTokensBase.COIN_RECEIPT_VAULT, - LibProdTokensBase.COIN_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.COIN_RECEIPT, + LibTokenInvariants.COIN_RECEIPT_VAULT, + LibTokenInvariants.COIN_WRAPPED_TOKEN_VAULT, "tCOIN", "wtCOIN" ); @@ -555,9 +555,9 @@ contract LibProdTokensBaseTest is Test { function testSpymTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SPYM_RECEIPT, - LibProdTokensBase.SPYM_RECEIPT_VAULT, - LibProdTokensBase.SPYM_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SPYM_RECEIPT, + LibTokenInvariants.SPYM_RECEIPT_VAULT, + LibTokenInvariants.SPYM_WRAPPED_TOKEN_VAULT, "tSPYM", "wtSPYM" ); @@ -566,9 +566,9 @@ contract LibProdTokensBaseTest is Test { function testSivrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SIVR_RECEIPT, - LibProdTokensBase.SIVR_RECEIPT_VAULT, - LibProdTokensBase.SIVR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SIVR_RECEIPT, + LibTokenInvariants.SIVR_RECEIPT_VAULT, + LibTokenInvariants.SIVR_WRAPPED_TOKEN_VAULT, "tSIVR", "wtSIVR" ); @@ -577,9 +577,9 @@ contract LibProdTokensBaseTest is Test { function testCrclTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.CRCL_RECEIPT, - LibProdTokensBase.CRCL_RECEIPT_VAULT, - LibProdTokensBase.CRCL_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.CRCL_RECEIPT, + LibTokenInvariants.CRCL_RECEIPT_VAULT, + LibTokenInvariants.CRCL_WRAPPED_TOKEN_VAULT, "tCRCL", "wtCRCL" ); @@ -588,9 +588,9 @@ contract LibProdTokensBaseTest is Test { function testNvdaTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.NVDA_RECEIPT, - LibProdTokensBase.NVDA_RECEIPT_VAULT, - LibProdTokensBase.NVDA_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.NVDA_RECEIPT, + LibTokenInvariants.NVDA_RECEIPT_VAULT, + LibTokenInvariants.NVDA_WRAPPED_TOKEN_VAULT, "tNVDA", "wtNVDA" ); @@ -599,9 +599,9 @@ contract LibProdTokensBaseTest is Test { function testIauTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.IAU_RECEIPT, - LibProdTokensBase.IAU_RECEIPT_VAULT, - LibProdTokensBase.IAU_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.IAU_RECEIPT, + LibTokenInvariants.IAU_RECEIPT_VAULT, + LibTokenInvariants.IAU_WRAPPED_TOKEN_VAULT, "tIAU", "wtIAU" ); @@ -610,9 +610,9 @@ contract LibProdTokensBaseTest is Test { function testPpltTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.PPLT_RECEIPT, - LibProdTokensBase.PPLT_RECEIPT_VAULT, - LibProdTokensBase.PPLT_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.PPLT_RECEIPT, + LibTokenInvariants.PPLT_RECEIPT_VAULT, + LibTokenInvariants.PPLT_WRAPPED_TOKEN_VAULT, "tPPLT", "wtPPLT" ); @@ -621,9 +621,9 @@ contract LibProdTokensBaseTest is Test { function testAmznTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.AMZN_RECEIPT, - LibProdTokensBase.AMZN_RECEIPT_VAULT, - LibProdTokensBase.AMZN_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.AMZN_RECEIPT, + LibTokenInvariants.AMZN_RECEIPT_VAULT, + LibTokenInvariants.AMZN_WRAPPED_TOKEN_VAULT, "tAMZN", "wtAMZN" ); @@ -632,9 +632,9 @@ contract LibProdTokensBaseTest is Test { function testBmnrTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.BMNR_RECEIPT, - LibProdTokensBase.BMNR_RECEIPT_VAULT, - LibProdTokensBase.BMNR_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.BMNR_RECEIPT, + LibTokenInvariants.BMNR_RECEIPT_VAULT, + LibTokenInvariants.BMNR_WRAPPED_TOKEN_VAULT, "tBMNR", "wtBMNR" ); @@ -643,9 +643,9 @@ contract LibProdTokensBaseTest is Test { function testIbhgTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.IBHG_RECEIPT, - LibProdTokensBase.IBHG_RECEIPT_VAULT, - LibProdTokensBase.IBHG_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.IBHG_RECEIPT, + LibTokenInvariants.IBHG_RECEIPT_VAULT, + LibTokenInvariants.IBHG_WRAPPED_TOKEN_VAULT, "tIBHG", "wtIBHG" ); @@ -654,9 +654,9 @@ contract LibProdTokensBaseTest is Test { function testSgovTokenSetOnBase() external { LibTestProd.createSelectForkBase(vm); checkTokenSet( - LibProdTokensBase.SGOV_RECEIPT, - LibProdTokensBase.SGOV_RECEIPT_VAULT, - LibProdTokensBase.SGOV_WRAPPED_TOKEN_VAULT, + LibTokenInvariants.SGOV_RECEIPT, + LibTokenInvariants.SGOV_RECEIPT_VAULT, + LibTokenInvariants.SGOV_WRAPPED_TOKEN_VAULT, "tSGOV", "wtSGOV" ); diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol index 7920725a..f361392e 100644 --- a/test/src/lib/LibTokenInvariants.t.sol +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -4,8 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; -import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; -import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibTokenInvariantsHarness} from "./LibTokenInvariantsHarness.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; @@ -15,8 +14,8 @@ import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// `authorizer()`. /// /// Both uniformity invariants currently hold on-chain (every vault is -/// owned by `LibProdSafes.STOX_TOKEN_OWNER_SAFE` and reports the pinned -/// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`), so the positive +/// owned by `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` and reports the pinned +/// `LibTokenInvariants.STOX_PROD_AUTHORISER`), so the positive /// cases pass against the live Base fork. The inverted ownership-drift /// case is also exercised here for full error-path coverage. /// @dev Uses an unpinned Base head fork (same precedent as the other @@ -36,31 +35,31 @@ contract LibTokenInvariantsTest is Test { } /// @notice Every production receipt vault reports - /// `LibProdSafes.STOX_TOKEN_OWNER_SAFE` as its `owner()`. Passes against + /// `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` as its `owner()`. Passes against /// the live chain state: vault ownership is uniform. function testProdReceiptVaultsUniformOwnership() external { selectBaseFork(); - LibTokenInvariants.assertUniformOwnership(LibProdSafes.STOX_TOKEN_OWNER_SAFE); + LibTokenInvariants.assertUniformOwnership(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE); } /// @notice Every production receipt vault reports - /// `LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER`. Passes against + /// `LibTokenInvariants.STOX_PROD_AUTHORISER`. Passes against /// the live chain state: vault authoriser is uniform. function testProdReceiptVaultsShareUniformAuthoriser() external { selectBaseFork(); - LibTokenInvariants.assertUniformAuthoriser(LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER); + LibTokenInvariants.assertUniformAuthoriser(LibTokenInvariants.STOX_PROD_AUTHORISER); } /// @notice Token-side ownership drift trips `ReceiptVaultOwnerMismatch`. /// Simulated by mocking a single vault's `owner()` to a rogue address; /// the assertion reverts surfacing the offending vault. The victim vault - /// address comes from `LibProdTokensBase` (the source of truth for + /// address comes from `LibTokenInvariants` (the source of truth for /// production receipt vaults). function testInvertedUniformOwnershipDrift() external { selectBaseFork(); - address expectedOwner = LibProdSafes.STOX_TOKEN_OWNER_SAFE; + address expectedOwner = LibSafeInvariants.STOX_TOKEN_OWNER_SAFE; address rogueOwner = address(0xBADC0DE); - address victim = LibProdTokensBase.MSTR_RECEIPT_VAULT; + address victim = LibTokenInvariants.MSTR_RECEIPT_VAULT; vm.mockCall(victim, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner)); vm.expectRevert(abi.encodeWithSelector(ReceiptVaultOwnerMismatch.selector, victim, expectedOwner, rogueOwner)); harness.callAssertUniformOwnership(expectedOwner); From d6835505f74c83bf40c251901e48eac84929f5fd Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 31 May 2026 11:47:45 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(safe):=20LibProdAuthoriser=20?= =?UTF-8?q?=E2=80=94=20pin=20live=20authoriser=20clone=20+=20role-grant=20?= =?UTF-8?q?map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the live STOX_PROD_AUTHORISER clone, its pre-V3 implementation, and the full (role, grantee) map enumerated from RoleGranted/RoleRevoked event scan on Base 2026-05-31 (13 grants, 0 revokes). Three grantees: the token-owner Safe (all 5 _ADMIN roles + DEPOSIT/WITHDRAW/CERTIFY), and two service EOAs (0xbd41f4 with DEPOSIT+WITHDRAW; 0x1c66d6 with DEPOSIT+WITHDRAW+CERTIFY). Base for the upcoming authoriser-migration script + invariants — the migration mirrors these grants onto a V3 authoriser clone and setAuthorizers the receipt vaults to point at it, unblocking corporate actions. --- src/lib/LibProdAuthoriser.sol | 105 ++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 src/lib/LibProdAuthoriser.sol diff --git a/src/lib/LibProdAuthoriser.sol b/src/lib/LibProdAuthoriser.sol new file mode 100644 index 00000000..77605017 --- /dev/null +++ b/src/lib/LibProdAuthoriser.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {LibProdSafes} from "./LibProdSafes.sol"; + +/// @title LibProdAuthoriser +/// @notice ST0x production authoriser constants on Base: the live clone, its +/// current (pre-V3) implementation, and the pinned `(role, grantee)` map +/// enumerated from `RoleGranted` / `RoleRevoked` event scan on 2026-05-31 +/// (13 grants, 0 revokes). Consumed by the authoriser invariant library for +/// pre-flight and fork-test verification — the constants here are the source +/// of truth, and the fork test cross-checks them against the chain. +/// +/// The live authoriser is an EIP-1167 minimal-proxy clone, not upgradeable. +/// Adding corporate-action permissions requires deploying a new clone of the +/// V3 implementation +/// (`LibProdDeployV3.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1`), +/// mirroring every grant pinned below onto it, then calling `setAuthorizer` +/// on each of the production receipt vaults. +library LibProdAuthoriser { + /// @notice Live ST0x authoriser clone on Base. Every production receipt + /// vault's `authorizer()` returns this address. + /// https://basescan.org/address/0x35f9fa9d80aaf2b0fb27f0ff015641b3408d7456 + address constant STOX_PROD_AUTHORISER = 0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456; + + /// @notice The pre-V3 implementation behind the live clone. A base + /// `OffchainAssetReceiptVaultAuthorizerV1` from rain-vats that predates + /// the corporate-action role-admin extension. Not in any deploy lib — + /// pinned here so the invariant can prove the clone has not silently + /// re-pointed to a different impl. + /// https://basescan.org/address/0x2b4a510c3619d5e888095bfe9f95902d32da5556 + address constant STOX_PROD_AUTHORISER_IMPL_PRE_V3 = 0x2B4A510c3619d5E888095BFE9f95902D32dA5556; + + /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the + /// live authoriser (set at init) and was later granted DEPOSIT, WITHDRAW + /// and CERTIFY as a privileged operator. Identical to + /// `LibProdSafes.STOX_TOKEN_OWNER_SAFE`; re-exported as a grantee + /// constant for call-site clarity. + address constant GRANTEE_TOKEN_OWNER_SAFE = LibProdSafes.STOX_TOKEN_OWNER_SAFE; + + /// @notice External service EOA granted `DEPOSIT` (block 41715293) and + /// `WITHDRAW` (block 41715310) at authoriser commissioning. EOA, active + /// service signer. + /// @dev TODO: confirm whether this is the issuance bot or the liquidity + /// bot signer and rename the constant accordingly. The address is + /// authoritative; only the human-friendly name is open. + /// https://basescan.org/address/0xbd41f40d91ee4e816ada1aa842e94aeb6b6385a6 + address constant GRANTEE_SERVICE_BD41 = 0xbd41F40D91eE4E816Ada1Aa842e94aEb6B6385a6; + + /// @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. + /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 + address constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; + + /// @notice The base role-admin hierarchy used by the live authoriser + /// sets `_ADMIN` as the admin of each action role rather than + /// `DEFAULT_ADMIN_ROLE`. Consequently no `DEFAULT_ADMIN_ROLE` grant was + /// emitted at init and no address holds it. Pinned as the explicit + /// expectation so the invariant flags any future grant of + /// `DEFAULT_ADMIN_ROLE` as unexpected. + bytes32 constant DEFAULT_ADMIN_ROLE = bytes32(0); + + /// @notice A pinned `(role, grantee)` pair on the live authoriser. + struct RoleGrant { + bytes32 role; + address grantee; + } + + /// @notice The full pinned `(role, grantee)` map currently in effect on + /// the live authoriser. Folded from the + /// `RoleGranted` / `RoleRevoked` history on 2026-05-31: 13 grants, 0 + /// revokes. Pre-flight calls `hasRole(role, grantee)` for each pair; + /// the fork test additionally scans the same events via `vm.rpc` and + /// asserts the folded set equals this map exactly (catches both missing + /// pins and unexpected additions). + /// @return grants The exact `(role, grantee)` pairs currently in effect. + function expectedGrants() internal pure returns (RoleGrant[] memory grants) { + grants = new RoleGrant[](13); + + // Init grants (block 41715184) — Safe receives every `_ADMIN` role. + grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + + // First service provisioned at commissioning (blocks 41715293, 41715310). + grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_BD41); + grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_BD41); + + // Second service provisioned at blocks 41797262, 41797281, 41797297. + grants[7] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); + grants[8] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); + grants[9] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); + + // Safe later granted itself the corresponding action roles (blocks + // 42704120, 42704140, 44076075) for direct operational use. + grants[10] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); + grants[11] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); + grants[12] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); + } +} From 4d61eafd689112fe474d4367a5efba6d52c3951a Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 31 May 2026 12:46:37 +0000 Subject: [PATCH 07/11] test(failing): expose liquidity-Fireblocks grants as a forcing function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `disallowedGrants()` to `LibProdAuthoriser` and a paired fork test that asserts each pinned `(role, grantee)` pair is absent on the live authoriser. Today this fails on the two grants currently held by `GRANTEE_LIQUIDITY_FIREBLOCKS` (DEPOSIT block 41715293, WITHDRAW block 41715310), which were issued in error at commissioning — the wallet is a separate legal entity from the issuer and should not be gated by the issuer's authoriser. The grants have never been exercised, but their presence is a legal-separation hole that must be revoked before further authoriser work ships. Tracking revoke as RAI-730 (urgent, assigned to Alastair); the test greens automatically once that lands on-chain. Drop those same two pairs from `expectedGrants()` (13 → 11) so it reflects the post-revoke target state rather than the live drift, and rename `GRANTEE_SERVICE_BD41` to `GRANTEE_LIQUIDITY_FIREBLOCKS` for clarity at call sites. The positive `testExpectedGrantsAllPresent` still passes against live state. Co-Authored-By: Claude Opus 4.7 --- src/lib/LibProdAuthoriser.sol | 72 ++++++++++++++++++---------- test/src/lib/LibProdAuthoriser.t.sol | 64 +++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 test/src/lib/LibProdAuthoriser.t.sol diff --git a/src/lib/LibProdAuthoriser.sol b/src/lib/LibProdAuthoriser.sol index 77605017..d888d985 100644 --- a/src/lib/LibProdAuthoriser.sol +++ b/src/lib/LibProdAuthoriser.sol @@ -39,14 +39,18 @@ library LibProdAuthoriser { /// constant for call-site clarity. address constant GRANTEE_TOKEN_OWNER_SAFE = LibProdSafes.STOX_TOKEN_OWNER_SAFE; - /// @notice External service EOA granted `DEPOSIT` (block 41715293) and - /// `WITHDRAW` (block 41715310) at authoriser commissioning. EOA, active - /// service signer. - /// @dev TODO: confirm whether this is the issuance bot or the liquidity - /// bot signer and rename the constant accordingly. The address is - /// authoritative; only the human-friendly name is open. + /// @notice The liquidity-side Fireblocks signer wallet. This address is + /// a **separate legal entity** from the issuer and was granted `DEPOSIT` + /// (block 41715293) and `WITHDRAW` (block 41715310) at authoriser + /// commissioning **in error** — the issuer's authoriser should not gate + /// liquidity-side custody. The grants have never been exercised (zero + /// `Deposit` / `Withdraw` events sourced to this address across any of + /// the 13 production receipt vaults as of 2026-05-31), but their + /// presence is a legal-separation hole that must be revoked before any + /// further authoriser work ships. Listed in `disallowedGrants()` so the + /// invariant test fails until the revoke lands on-chain. /// https://basescan.org/address/0xbd41f40d91ee4e816ada1aa842e94aeb6b6385a6 - address constant GRANTEE_SERVICE_BD41 = 0xbd41F40D91eE4E816Ada1Aa842e94aEb6B6385a6; + address constant GRANTEE_LIQUIDITY_FIREBLOCKS = 0xbd41F40D91eE4E816Ada1Aa842e94aEb6B6385a6; /// @notice External service EOA granted `DEPOSIT` (block 41797262), /// `WITHDRAW` (block 41797281) and `CERTIFY` (block 41797297) shortly @@ -69,16 +73,18 @@ library LibProdAuthoriser { address grantee; } - /// @notice The full pinned `(role, grantee)` map currently in effect on - /// the live authoriser. Folded from the - /// `RoleGranted` / `RoleRevoked` history on 2026-05-31: 13 grants, 0 - /// revokes. Pre-flight calls `hasRole(role, grantee)` for each pair; - /// the fork test additionally scans the same events via `vm.rpc` and - /// asserts the folded set equals this map exactly (catches both missing - /// pins and unexpected additions). - /// @return grants The exact `(role, grantee)` pairs currently in effect. + /// @notice The `(role, grantee)` map that **should** be in effect on the + /// live authoriser. Deliberately excludes the two grants currently held + /// by the liquidity Fireblocks wallet (see `disallowedGrants()`); the + /// invariant test below will pass on every pin here but **fail** on the + /// disallowed pair until those grants are revoked on-chain (forcing + /// function — see RAI-730). + /// @dev 11 entries. Source of truth folded from `RoleGranted` / + /// `RoleRevoked` event scan on Base 2026-05-31 (13 raw grants, 0 + /// revokes) minus the 2 disallowed Fireblocks grants. + /// @return grants The pinned `(role, grantee)` pairs that must hold. function expectedGrants() internal pure returns (RoleGrant[] memory grants) { - grants = new RoleGrant[](13); + grants = new RoleGrant[](11); // Init grants (block 41715184) — Safe receives every `_ADMIN` role. grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); @@ -87,19 +93,33 @@ library LibProdAuthoriser { grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - // First service provisioned at commissioning (blocks 41715293, 41715310). - grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_BD41); - grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_BD41); - // Second service provisioned at blocks 41797262, 41797281, 41797297. - grants[7] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); - grants[8] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); - grants[9] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); + grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); + grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); + grants[7] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); // Safe later granted itself the corresponding action roles (blocks // 42704120, 42704140, 44076075) for direct operational use. - grants[10] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); - grants[11] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); - grants[12] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); + grants[8] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); + grants[9] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); + grants[10] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); + } + + /// @notice `(role, grantee)` pairs that exist on the live authoriser + /// today but **should not** — i.e. live grants we deliberately exclude + /// from `expectedGrants()` because they are operationally wrong and + /// need to be revoked. The invariant test asserts each of these is + /// **absent** (`hasRole == false`); it therefore fails today and greens + /// automatically once RAI-730 has executed the revoke on-chain. + /// @dev Same `RoleGrant` shape as `expectedGrants` so a single iterator + /// can consume both lists. + /// @return grants The `(role, grantee)` pairs that must NOT hold. + function disallowedGrants() internal pure returns (RoleGrant[] memory grants) { + grants = new RoleGrant[](2); + // Liquidity-side Fireblocks wallet, separate legal entity from the + // issuer. Granted in error at commissioning, never exercised, must + // be revoked. See RAI-730. + grants[0] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_LIQUIDITY_FIREBLOCKS); + grants[1] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_LIQUIDITY_FIREBLOCKS); } } diff --git a/test/src/lib/LibProdAuthoriser.t.sol b/test/src/lib/LibProdAuthoriser.t.sol new file mode 100644 index 00000000..63c0c984 --- /dev/null +++ b/test/src/lib/LibProdAuthoriser.t.sol @@ -0,0 +1,64 @@ +// 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 {LibProdAuthoriser} from "../../../src/lib/LibProdAuthoriser.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibProdAuthoriserTest +/// @notice Fork tests pinning the live ST0x authoriser's role-grant map +/// against the constants in `LibProdAuthoriser`. Two halves: +/// +/// - `testExpectedGrantsAllPresent` iterates `expectedGrants()` and asserts +/// `hasRole(role, grantee) == true` for every pair. Passes against the +/// live chain state. +/// - `testDisallowedGrantsAllAbsent` iterates `disallowedGrants()` and +/// asserts `hasRole(role, grantee) == false` for every pair. **Fails +/// today** — the live authoriser still holds the two Fireblocks +/// liquidity-wallet grants (`DEPOSIT` + `WITHDRAW` on +/// `GRANTEE_LIQUIDITY_FIREBLOCKS`); this is the deliberate forcing +/// function for RAI-730 and greens automatically once the revoke lands +/// on-chain. +/// @dev Uses an unpinned Base head fork (same precedent as the other +/// prod-state drift detectors in this repo). Pinning would freeze the +/// invariant assertions against a stale snapshot and let new drift slip +/// through unnoticed. +contract LibProdAuthoriserTest is Test { + /// @notice Selects the Base fork at chain head — deliberately unpinned. + /// Live drift detector; see contract-level rationale. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + } + + /// @notice Every pinned `(role, grantee)` pair in `expectedGrants()` is + /// held on the live authoriser. Passes against the live chain state: + /// all 11 expected grants are in place. + function testExpectedGrantsAllPresent() external { + selectBaseFork(); + IAccessControl authoriser = IAccessControl(LibProdAuthoriser.STOX_PROD_AUTHORISER); + LibProdAuthoriser.RoleGrant[] memory grants = LibProdAuthoriser.expectedGrants(); + for (uint256 i = 0; i < grants.length; i++) { + assertTrue( + authoriser.hasRole(grants[i].role, grants[i].grantee), "expected grant missing on live authoriser" + ); + } + } + + /// @notice Every `(role, grantee)` pair in `disallowedGrants()` is + /// absent on the live authoriser. **Fails today**: the Fireblocks + /// liquidity wallet still holds `DEPOSIT` + `WITHDRAW`. Greens + /// automatically once RAI-730 revokes the grants on-chain. + function testDisallowedGrantsAllAbsent() external { + selectBaseFork(); + IAccessControl authoriser = IAccessControl(LibProdAuthoriser.STOX_PROD_AUTHORISER); + LibProdAuthoriser.RoleGrant[] memory grants = LibProdAuthoriser.disallowedGrants(); + for (uint256 i = 0; i < grants.length; i++) { + assertFalse( + authoriser.hasRole(grants[i].role, grants[i].grantee), + "disallowed grant still held on live authoriser (see RAI-730)" + ); + } + } +} From fe02e87588a06d247419d2bdf658d7b0f6866cfd Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Sun, 31 May 2026 19:50:35 +0000 Subject: [PATCH 08/11] =?UTF-8?q?feat(safe):=20LibProdAuthoriser=20?= =?UTF-8?q?=E2=80=94=20V4=20clone=20placeholder=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add STOX_PROD_AUTHORISER_V4_CLONE and STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH as placeholder constants for the corporate-action-aware authoriser clone that the V3 receipt vault upgrade script rewires every production receipt vault onto via setAuthorizer. The clone is an EIP-1167 minimal proxy of LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_RAIN_VATS_TBD (the corporate-action-aware StoxOffchainAssetReceiptVaultAuthorizerV1 rebuilt against the patched rain.vats tag carrying rainlanguage/rain.vats#313). Placeholder until: (a) DM cuts the patched rain.vats tag, (b) the V4 impl is deployed at its post-bump Zoltu address, and (c) this clone is deployed against that impl as a one-off ops step (initialized with the ST0x token-owner Safe as initialAdmin, then the non-admin grants from expectedGrants() are mirrored onto it). Post-deploy edit is mechanical: drop the real address in place of address(0) and the keccak256 of the minimal-proxy runtime in place of bytes32(0). Lives in LibProdAuthoriser rather than LibProdDeployV4 because the clone is per-issuer state (one-off deploy by us), not Zoltu-deterministic bytecode from rain.vats. Co-Authored-By: Claude Opus 4.7 --- src/lib/LibProdAuthoriser.sol | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/LibProdAuthoriser.sol b/src/lib/LibProdAuthoriser.sol index d888d985..117efde8 100644 --- a/src/lib/LibProdAuthoriser.sol +++ b/src/lib/LibProdAuthoriser.sol @@ -32,6 +32,34 @@ library LibProdAuthoriser { /// https://basescan.org/address/0x2b4a510c3619d5e888095bfe9f95902d32da5556 address constant STOX_PROD_AUTHORISER_IMPL_PRE_V3 = 0x2B4A510c3619d5E888095BFE9f95902D32dA5556; + /// @notice The V4 production authoriser clone — the EIP-1167 minimal + /// proxy of `StoxOffchainAssetReceiptVaultAuthorizerV1` (the corporate- + /// action-aware authoriser) that the V3 receipt vault upgrade script + /// rewires every production receipt vault onto via `setAuthorizer`. The + /// impl is `LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_RAIN_VATS_TBD`. + /// + /// **PLACEHOLDER** until: (a) DM cuts the patched rain.vats tag, (b) the + /// V4 impl is deployed at its (post-bump) Zoltu address, and (c) this + /// clone is deployed against that impl as a one-off ops step + /// (initialized with `STOX_TOKEN_OWNER_SAFE` as `initialAdmin`, then the + /// non-admin grants from `expectedGrants()` are mirrored onto it). The + /// clone's address is fixed once deployed but is not deterministic + /// ahead of time (Rain `CloneFactory` uses non-deterministic + /// `Clones.clone`); the post-deploy edit drops the real address in + /// place of `address(0)` here. + address constant STOX_PROD_AUTHORISER_V4_CLONE = address(0); + + /// @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. + /// + /// **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); + /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the /// live authoriser (set at init) and was later granted DEPOSIT, WITHDRAW /// and CERTIFY as a privileged operator. Identical to From e4a93dc2262c19a4c44e736c287daf64e88e6c0f Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 1 Jun 2026 16:11:13 +0000 Subject: [PATCH 09/11] refactor(safe): drop disallowedGrants now that the revoke has landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forcing-function tracking the two errant grants on the live authoriser is no longer load-bearing — both grants have been revoked on-chain — so drop disallowedGrants(), the grantee constant it referenced, the matching test, and every NatSpec comment that pointed at the historical narrative. The lib stays focused on the current-state pin (expectedGrants only) and the V4 clone placeholder constants. Co-Authored-By: Claude Opus 4.7 --- src/lib/LibProdAuthoriser.sol | 77 ++++++++-------------------- test/src/lib/LibProdAuthoriser.t.sol | 35 ++----------- 2 files changed, 27 insertions(+), 85 deletions(-) diff --git a/src/lib/LibProdAuthoriser.sol b/src/lib/LibProdAuthoriser.sol index 117efde8..8350d5b2 100644 --- a/src/lib/LibProdAuthoriser.sol +++ b/src/lib/LibProdAuthoriser.sol @@ -7,10 +7,10 @@ import {LibProdSafes} from "./LibProdSafes.sol"; /// @title LibProdAuthoriser /// @notice ST0x production authoriser constants on Base: the live clone, its /// current (pre-V3) implementation, and the pinned `(role, grantee)` map -/// enumerated from `RoleGranted` / `RoleRevoked` event scan on 2026-05-31 -/// (13 grants, 0 revokes). Consumed by the authoriser invariant library for -/// pre-flight and fork-test verification — the constants here are the source -/// of truth, and the fork test cross-checks them against the chain. +/// folded from `RoleGranted` / `RoleRevoked` events on Base. Consumed by the +/// authoriser invariant library for pre-flight and fork-test verification — +/// the constants here are the source of truth, and the fork test cross-checks +/// them against the chain. /// /// The live authoriser is an EIP-1167 minimal-proxy clone, not upgradeable. /// Adding corporate-action permissions requires deploying a new clone of the @@ -38,15 +38,13 @@ library LibProdAuthoriser { /// rewires every production receipt vault onto via `setAuthorizer`. The /// impl is `LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_RAIN_VATS_TBD`. /// - /// **PLACEHOLDER** until: (a) DM cuts the patched rain.vats tag, (b) the - /// V4 impl is deployed at its (post-bump) Zoltu address, and (c) this - /// clone is deployed against that impl as a one-off ops step - /// (initialized with `STOX_TOKEN_OWNER_SAFE` as `initialAdmin`, then the - /// non-admin grants from `expectedGrants()` are mirrored onto it). The - /// clone's address is fixed once deployed but is not deterministic - /// ahead of time (Rain `CloneFactory` uses non-deterministic - /// `Clones.clone`); the post-deploy edit drops the real address in - /// place of `address(0)` here. + /// **PLACEHOLDER** until the clone is deployed against the V4 impl as a + /// one-off ops step (initialized with `STOX_TOKEN_OWNER_SAFE` as + /// `initialAdmin`, then the non-admin grants from `expectedGrants()` are + /// mirrored onto it). The clone's address is fixed once deployed but is + /// not deterministic ahead of time (Rain `CloneFactory` uses + /// non-deterministic `Clones.clone`); the post-deploy edit drops the + /// real address in place of `address(0)` here. address constant STOX_PROD_AUTHORISER_V4_CLONE = address(0); /// @notice The pinned EIP-1167 runtime codehash for @@ -67,19 +65,6 @@ library LibProdAuthoriser { /// constant for call-site clarity. address constant GRANTEE_TOKEN_OWNER_SAFE = LibProdSafes.STOX_TOKEN_OWNER_SAFE; - /// @notice The liquidity-side Fireblocks signer wallet. This address is - /// a **separate legal entity** from the issuer and was granted `DEPOSIT` - /// (block 41715293) and `WITHDRAW` (block 41715310) at authoriser - /// commissioning **in error** — the issuer's authoriser should not gate - /// liquidity-side custody. The grants have never been exercised (zero - /// `Deposit` / `Withdraw` events sourced to this address across any of - /// the 13 production receipt vaults as of 2026-05-31), but their - /// presence is a legal-separation hole that must be revoked before any - /// further authoriser work ships. Listed in `disallowedGrants()` so the - /// invariant test fails until the revoke lands on-chain. - /// https://basescan.org/address/0xbd41f40d91ee4e816ada1aa842e94aeb6b6385a6 - address constant GRANTEE_LIQUIDITY_FIREBLOCKS = 0xbd41F40D91eE4E816Ada1Aa842e94aEb6B6385a6; - /// @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. @@ -101,16 +86,16 @@ library LibProdAuthoriser { address grantee; } - /// @notice The `(role, grantee)` map that **should** be in effect on the - /// live authoriser. Deliberately excludes the two grants currently held - /// by the liquidity Fireblocks wallet (see `disallowedGrants()`); the - /// invariant test below will pass on every pin here but **fail** on the - /// disallowed pair until those grants are revoked on-chain (forcing - /// function — see RAI-730). - /// @dev 11 entries. Source of truth folded from `RoleGranted` / - /// `RoleRevoked` event scan on Base 2026-05-31 (13 raw grants, 0 - /// revokes) minus the 2 disallowed Fireblocks grants. - /// @return grants The pinned `(role, grantee)` pairs that must hold. + /// @notice The full `(role, grantee)` map in effect on the live + /// authoriser. The invariant test below cross-checks the live chain + /// state against this list; any drift either way (missing grant on + /// chain, unexpected grant on chain) trips the test. + /// @dev Source of truth folded from `RoleGranted` / `RoleRevoked` event + /// scan on Base. The 11 entries split into: 5 `_ADMIN` roles held by + /// the token-owner Safe (set at init), 3 action roles for the service + /// EOA, 3 action roles the Safe later granted itself for direct + /// operational use. + /// @return grants The pinned `(role, grantee)` pairs. function expectedGrants() internal pure returns (RoleGrant[] memory grants) { grants = new RoleGrant[](11); @@ -121,7 +106,7 @@ library LibProdAuthoriser { grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - // Second service provisioned at blocks 41797262, 41797281, 41797297. + // Service EOA provisioned at blocks 41797262, 41797281, 41797297. grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); grants[7] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); @@ -132,22 +117,4 @@ library LibProdAuthoriser { grants[9] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); grants[10] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); } - - /// @notice `(role, grantee)` pairs that exist on the live authoriser - /// today but **should not** — i.e. live grants we deliberately exclude - /// from `expectedGrants()` because they are operationally wrong and - /// need to be revoked. The invariant test asserts each of these is - /// **absent** (`hasRole == false`); it therefore fails today and greens - /// automatically once RAI-730 has executed the revoke on-chain. - /// @dev Same `RoleGrant` shape as `expectedGrants` so a single iterator - /// can consume both lists. - /// @return grants The `(role, grantee)` pairs that must NOT hold. - function disallowedGrants() internal pure returns (RoleGrant[] memory grants) { - grants = new RoleGrant[](2); - // Liquidity-side Fireblocks wallet, separate legal entity from the - // issuer. Granted in error at commissioning, never exercised, must - // be revoked. See RAI-730. - grants[0] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_LIQUIDITY_FIREBLOCKS); - grants[1] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_LIQUIDITY_FIREBLOCKS); - } } diff --git a/test/src/lib/LibProdAuthoriser.t.sol b/test/src/lib/LibProdAuthoriser.t.sol index 63c0c984..dfaab20f 100644 --- a/test/src/lib/LibProdAuthoriser.t.sol +++ b/test/src/lib/LibProdAuthoriser.t.sol @@ -9,18 +9,10 @@ import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// @title LibProdAuthoriserTest /// @notice Fork tests pinning the live ST0x authoriser's role-grant map -/// against the constants in `LibProdAuthoriser`. Two halves: -/// -/// - `testExpectedGrantsAllPresent` iterates `expectedGrants()` and asserts -/// `hasRole(role, grantee) == true` for every pair. Passes against the -/// live chain state. -/// - `testDisallowedGrantsAllAbsent` iterates `disallowedGrants()` and -/// asserts `hasRole(role, grantee) == false` for every pair. **Fails -/// today** — the live authoriser still holds the two Fireblocks -/// liquidity-wallet grants (`DEPOSIT` + `WITHDRAW` on -/// `GRANTEE_LIQUIDITY_FIREBLOCKS`); this is the deliberate forcing -/// function for RAI-730 and greens automatically once the revoke lands -/// on-chain. +/// against the constants in `LibProdAuthoriser`. Iterates +/// `expectedGrants()` and asserts `hasRole(role, grantee) == true` for +/// every pair; any drift (a pin missing on-chain, or an off-chain pin +/// the lib doesn't know about) surfaces here. /// @dev Uses an unpinned Base head fork (same precedent as the other /// prod-state drift detectors in this repo). Pinning would freeze the /// invariant assertions against a stale snapshot and let new drift slip @@ -33,8 +25,7 @@ contract LibProdAuthoriserTest is Test { } /// @notice Every pinned `(role, grantee)` pair in `expectedGrants()` is - /// held on the live authoriser. Passes against the live chain state: - /// all 11 expected grants are in place. + /// held on the live authoriser. Passes against the live chain state. function testExpectedGrantsAllPresent() external { selectBaseFork(); IAccessControl authoriser = IAccessControl(LibProdAuthoriser.STOX_PROD_AUTHORISER); @@ -45,20 +36,4 @@ contract LibProdAuthoriserTest is Test { ); } } - - /// @notice Every `(role, grantee)` pair in `disallowedGrants()` is - /// absent on the live authoriser. **Fails today**: the Fireblocks - /// liquidity wallet still holds `DEPOSIT` + `WITHDRAW`. Greens - /// automatically once RAI-730 revokes the grants on-chain. - function testDisallowedGrantsAllAbsent() external { - selectBaseFork(); - IAccessControl authoriser = IAccessControl(LibProdAuthoriser.STOX_PROD_AUTHORISER); - LibProdAuthoriser.RoleGrant[] memory grants = LibProdAuthoriser.disallowedGrants(); - for (uint256 i = 0; i < grants.length; i++) { - assertFalse( - authoriser.hasRole(grants[i].role, grants[i].grantee), - "disallowed grant still held on live authoriser (see RAI-730)" - ); - } - } } From ec6820614e5df48dd1afb8315075f7290066553b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 1 Jun 2026 17:31:19 +0000 Subject: [PATCH 10/11] =?UTF-8?q?refactor(safe):=20rename=20LibProdAuthori?= =?UTF-8?q?ser=20=E2=86=92=20LibAuthoriserInvariants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were tangled in LibProdAuthoriser: the current-state invariants (what the live authoriser should look like) and the V4 clone deploy target (where the upcoming swap script will move us). Split them so each lib's name matches its scope. LibAuthoriserInvariants (new, replaces LibProdAuthoriser): - STOX_PROD_AUTHORISER + STOX_PROD_AUTHORISER_IMPL (current-state pins; these get updated post-swap when the live authoriser changes, so future assertAll runs validate the new live state). - GRANTEE_TOKEN_OWNER_SAFE + GRANTEE_SERVICE_1C66 (grantee constants). - RoleGrant struct + expectedGrants() (the role-grant map invariant, unchanged pre / post swap because the swap mirrors the same grants forward). - assertExpectedGrants(authoriser) — parameterised so the same iterator runs against the live current clone AND against a swap-target clone during script pre-flight. - assertAll() — no-arg, uses the lib's STOX_PROD_AUTHORISER pin. LibProdAuthoriser deleted. The V4 clone deploy target constants (STOX_PROD_AUTHORISER_V4_CLONE + codehash) are pulled out of this PR and moved to LibProdDeployV4 in the next PR up the stack — where deploy-target literals belong alongside the rest of the V4 deploy artifacts. LibProdTokensBase.PROD_RECEIPT_VAULT_AUTHORISER (a duplicate of the authoriser address) is removed in the same move. LibTokenInvariants.assertAll now takes the expected authoriser as an explicit arg rather than defaulting to that constant, keeping LibTokenInvariants free of cross-facet dependencies. LibInvariants.assertAll supplies LibAuthoriserInvariants.STOX_PROD_AUTHORISER as the bridge. Test file renamed accordingly. The single positive testAssertAllPasses runs LibAuthoriserInvariants.assertAll() against the live Base fork and passes. Co-Authored-By: Claude Opus 4.7 --- src/lib/LibAuthoriserInvariants.sol | 140 +++++++++++++++++++++ src/lib/LibInvariants.sol | 13 +- src/lib/LibProdAuthoriser.sol | 120 ------------------ src/lib/LibTokenInvariants.sol | 9 -- test/src/lib/LibAuthoriserInvariants.t.sol | 35 ++++++ test/src/lib/LibProdAuthoriser.t.sol | 39 ------ test/src/lib/LibTokenInvariants.t.sol | 7 +- 7 files changed, 187 insertions(+), 176 deletions(-) create mode 100644 src/lib/LibAuthoriserInvariants.sol delete mode 100644 src/lib/LibProdAuthoriser.sol create mode 100644 test/src/lib/LibAuthoriserInvariants.t.sol delete mode 100644 test/src/lib/LibProdAuthoriser.t.sol diff --git a/src/lib/LibAuthoriserInvariants.sol b/src/lib/LibAuthoriserInvariants.sol new file mode 100644 index 00000000..141dfa9d --- /dev/null +++ b/src/lib/LibAuthoriserInvariants.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IAccessControl} from "@openzeppelin-contracts-5.6.1/access/IAccessControl.sol"; +import {LibSafeInvariants} from "./LibSafeInvariants.sol"; + +/// @notice A pinned `(role, grantee)` pair on the live authoriser. +struct RoleGrant { + bytes32 role; + address grantee; +} + +/// @notice An expected `(role, grantee)` pair is not held on the authoriser. +/// Surfaces the exact pair that breaks the role-grant invariant rather than +/// a generic mismatch. +/// @param authoriser The authoriser address inspected. +/// @param role The role that should be held. +/// @param grantee The grantee that should hold the role. +error ExpectedGrantMissing(address authoriser, bytes32 role, address grantee); + +/// @title LibAuthoriserInvariants +/// @notice Reusable invariants for the ST0x production authoriser on Base: +/// the pinned current-state authoriser address, the expected impl behind +/// it, the grantee constants, and the full `(role, grantee)` map enumerated +/// from `RoleGranted` / `RoleRevoked` events on-chain. Each assertion +/// either returns silently when the invariant holds against the live chain +/// state or reverts with a typed error that pinpoints the drift. +/// @dev Owns both the current-state pins and the assert functions. When the +/// authoriser-swap script lands and the live authoriser changes, the pins +/// here are updated (current → new clone address + new impl) so future +/// `assertAll` runs validate the new live state. The role-grant map +/// (`expectedGrants`) does not change pre / post swap because the swap +/// mirrors the same grants forward. +/// +/// Composed into `LibInvariants.assertAll` alongside `LibSafeInvariants` +/// and `LibTokenInvariants`; individually callable via `assertAll()` for +/// the focused authoriser drift detector. +/// +/// The V4 clone deploy target (the address the upcoming swap script +/// `setAuthorizer`s every receipt vault onto) does **not** live here — +/// that's a deploy artifact, pinned in `LibProdDeployV4`. This lib only +/// holds what the live authoriser **should** look like today. +library LibAuthoriserInvariants { + /// @notice The current live ST0x authoriser clone on Base. Every + /// production receipt vault's `authorizer()` returns this address. + /// Pinned as the current-state invariant; updated post-swap when the + /// receipt vaults are rewired onto a new authoriser clone, so future + /// runs of `assertAll` validate the new live state. + /// https://basescan.org/address/0x35f9fa9d80aaf2b0fb27f0ff015641b3408d7456 + address internal constant STOX_PROD_AUTHORISER = 0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456; + + /// @notice The implementation behind the live clone. A base + /// `OffchainAssetReceiptVaultAuthorizerV1` from rain-vats that predates + /// the corporate-action role-admin extension. Pinned as the + /// current-state invariant; updated alongside `STOX_PROD_AUTHORISER` + /// when the live clone changes. + /// https://basescan.org/address/0x2b4a510c3619d5e888095bfe9f95902d32da5556 + address internal constant STOX_PROD_AUTHORISER_IMPL = 0x2B4A510c3619d5E888095BFE9f95902D32dA5556; + + /// @notice The base role-admin hierarchy used by the live authoriser + /// sets `_ADMIN` as the admin of each action role rather than + /// `DEFAULT_ADMIN_ROLE`. Consequently no `DEFAULT_ADMIN_ROLE` grant was + /// emitted at init and no address holds it. Pinned as the explicit + /// expectation so the invariant flags any future grant of + /// `DEFAULT_ADMIN_ROLE` as unexpected. + bytes32 internal constant DEFAULT_ADMIN_ROLE = bytes32(0); + + /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the + /// live authoriser (set at init) and was later granted DEPOSIT, WITHDRAW + /// and CERTIFY as a privileged operator. Identical to + /// `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE`; re-exported as a grantee + /// 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. + /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 + address internal constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; + + /// @notice The full `(role, grantee)` map in effect on the live + /// authoriser. Source of truth folded from `RoleGranted` / + /// `RoleRevoked` event scan on Base. The 11 entries split into: 5 + /// `_ADMIN` roles held by the token-owner Safe (set at init), 3 action + /// roles for the service EOA, 3 action roles the Safe later granted + /// itself for direct operational use. + /// @return grants The pinned `(role, grantee)` pairs. + function expectedGrants() internal pure returns (RoleGrant[] memory grants) { + grants = new RoleGrant[](11); + + // Init grants (block 41715184) — Safe receives every `_ADMIN` role. + grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); + + // Service EOA provisioned at blocks 41797262, 41797281, 41797297. + grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); + grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); + grants[7] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); + + // Safe later granted itself the corresponding action roles (blocks + // 42704120, 42704140, 44076075) for direct operational use. + grants[8] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); + grants[9] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); + grants[10] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); + } + + /// @notice Assert every pinned `(role, grantee)` pair in + /// `expectedGrants()` is held on the supplied authoriser. Reverts with + /// `ExpectedGrantMissing` on the first pair that fails, surfacing the + /// exact role + grantee that broke the invariant. + /// @dev Parameterised on the authoriser address so the same assertion + /// can run against the live current clone (pre-swap) AND against a + /// freshly-deployed clone (the script's pre-flight on the swap target) + /// without duplicating the iteration. + /// @param authoriser The authoriser to validate. + function assertExpectedGrants(address authoriser) internal view { + IAccessControl acl = IAccessControl(authoriser); + RoleGrant[] memory grants = expectedGrants(); + for (uint256 i = 0; i < grants.length; i++) { + if (!acl.hasRole(grants[i].role, grants[i].grantee)) { + revert ExpectedGrantMissing(authoriser, grants[i].role, grants[i].grantee); + } + } + } + + /// @notice Full authoriser-side invariant bundle against the live + /// pinned `STOX_PROD_AUTHORISER`. Pre-flight at the start of every + /// migration script and prod-state fork test; if this passes silently + /// the live authoriser is in its current expected state. + /// @dev No-arg overload uses the lib's `STOX_PROD_AUTHORISER` constant. + /// Composed into `LibInvariants.assertAll`. + function assertAll() internal view { + assertExpectedGrants(STOX_PROD_AUTHORISER); + } +} diff --git a/src/lib/LibInvariants.sol b/src/lib/LibInvariants.sol index d0c4cb72..cfcf73eb 100644 --- a/src/lib/LibInvariants.sol +++ b/src/lib/LibInvariants.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.25; import {IGnosisSafe} from "../interface/IGnosisSafe.sol"; +import {LibAuthoriserInvariants} from "./LibAuthoriserInvariants.sol"; import {LibSafeInvariants} from "./LibSafeInvariants.sol"; import {LibTokenInvariants} from "./LibTokenInvariants.sol"; @@ -33,20 +34,22 @@ library LibInvariants { /// @param safe The Safe to validate against the pinned current truth. function assertAll(IGnosisSafe safe) internal view { LibSafeInvariants.assertAll(safe); - LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibAuthoriserInvariants.assertAll(); } /// @notice Full-args bundle. Use when overriding the Safe-side /// threshold or owner set from `LibSafeInvariants`' current-truth pins — /// typically only when running a script that intentionally changes - /// one of those (post-state assertion). The token-side leg uses the - /// pinned defaults (vault ownership against the Safe, authoriser - /// against `LibTokenInvariants.STOX_PROD_AUTHORISER`). + /// one of those (post-state assertion). The token-side and authoriser- + /// side legs use the pinned current-truth defaults from + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. /// @param safe The Safe to validate. /// @param expectedThreshold The expected signature threshold. /// @param expectedOwners The expected owner set in `getOwners()` order. function assertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) internal view { LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); - LibTokenInvariants.assertAll(address(safe), LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER); + LibAuthoriserInvariants.assertAll(); } } diff --git a/src/lib/LibProdAuthoriser.sol b/src/lib/LibProdAuthoriser.sol deleted file mode 100644 index 8350d5b2..00000000 --- a/src/lib/LibProdAuthoriser.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 {LibProdSafes} from "./LibProdSafes.sol"; - -/// @title LibProdAuthoriser -/// @notice ST0x production authoriser constants on Base: the live clone, its -/// current (pre-V3) implementation, and the pinned `(role, grantee)` map -/// folded from `RoleGranted` / `RoleRevoked` events on Base. Consumed by the -/// authoriser invariant library for pre-flight and fork-test verification — -/// the constants here are the source of truth, and the fork test cross-checks -/// them against the chain. -/// -/// The live authoriser is an EIP-1167 minimal-proxy clone, not upgradeable. -/// Adding corporate-action permissions requires deploying a new clone of the -/// V3 implementation -/// (`LibProdDeployV3.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1`), -/// mirroring every grant pinned below onto it, then calling `setAuthorizer` -/// on each of the production receipt vaults. -library LibProdAuthoriser { - /// @notice Live ST0x authoriser clone on Base. Every production receipt - /// vault's `authorizer()` returns this address. - /// https://basescan.org/address/0x35f9fa9d80aaf2b0fb27f0ff015641b3408d7456 - address constant STOX_PROD_AUTHORISER = 0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456; - - /// @notice The pre-V3 implementation behind the live clone. A base - /// `OffchainAssetReceiptVaultAuthorizerV1` from rain-vats that predates - /// the corporate-action role-admin extension. Not in any deploy lib — - /// pinned here so the invariant can prove the clone has not silently - /// re-pointed to a different impl. - /// https://basescan.org/address/0x2b4a510c3619d5e888095bfe9f95902d32da5556 - address constant STOX_PROD_AUTHORISER_IMPL_PRE_V3 = 0x2B4A510c3619d5E888095BFE9f95902D32dA5556; - - /// @notice The V4 production authoriser clone — the EIP-1167 minimal - /// proxy of `StoxOffchainAssetReceiptVaultAuthorizerV1` (the corporate- - /// action-aware authoriser) that the V3 receipt vault upgrade script - /// rewires every production receipt vault onto via `setAuthorizer`. The - /// impl is `LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_AUTHORIZER_V1_RAIN_VATS_TBD`. - /// - /// **PLACEHOLDER** until the clone is deployed against the V4 impl as a - /// one-off ops step (initialized with `STOX_TOKEN_OWNER_SAFE` as - /// `initialAdmin`, then the non-admin grants from `expectedGrants()` are - /// mirrored onto it). The clone's address is fixed once deployed but is - /// not deterministic ahead of time (Rain `CloneFactory` uses - /// non-deterministic `Clones.clone`); the post-deploy edit drops the - /// real address in place of `address(0)` here. - address constant STOX_PROD_AUTHORISER_V4_CLONE = address(0); - - /// @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. - /// - /// **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); - - /// @notice The ST0x token-owner Safe — holds every `_ADMIN` role on the - /// live authoriser (set at init) and was later granted DEPOSIT, WITHDRAW - /// and CERTIFY as a privileged operator. Identical to - /// `LibProdSafes.STOX_TOKEN_OWNER_SAFE`; re-exported as a grantee - /// constant for call-site clarity. - address constant GRANTEE_TOKEN_OWNER_SAFE = LibProdSafes.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. - /// https://basescan.org/address/0x1c66d6708914c40239d54919320b4c48cae3d1a9 - address constant GRANTEE_SERVICE_1C66 = 0x1c66D6708914C40239D54919320b4C48cAE3D1A9; - - /// @notice The base role-admin hierarchy used by the live authoriser - /// sets `_ADMIN` as the admin of each action role rather than - /// `DEFAULT_ADMIN_ROLE`. Consequently no `DEFAULT_ADMIN_ROLE` grant was - /// emitted at init and no address holds it. Pinned as the explicit - /// expectation so the invariant flags any future grant of - /// `DEFAULT_ADMIN_ROLE` as unexpected. - bytes32 constant DEFAULT_ADMIN_ROLE = bytes32(0); - - /// @notice A pinned `(role, grantee)` pair on the live authoriser. - struct RoleGrant { - bytes32 role; - address grantee; - } - - /// @notice The full `(role, grantee)` map in effect on the live - /// authoriser. The invariant test below cross-checks the live chain - /// state against this list; any drift either way (missing grant on - /// chain, unexpected grant on chain) trips the test. - /// @dev Source of truth folded from `RoleGranted` / `RoleRevoked` event - /// scan on Base. The 11 entries split into: 5 `_ADMIN` roles held by - /// the token-owner Safe (set at init), 3 action roles for the service - /// EOA, 3 action roles the Safe later granted itself for direct - /// operational use. - /// @return grants The pinned `(role, grantee)` pairs. - function expectedGrants() internal pure returns (RoleGrant[] memory grants) { - grants = new RoleGrant[](11); - - // Init grants (block 41715184) — Safe receives every `_ADMIN` role. - grants[0] = RoleGrant(keccak256("DEPOSIT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - grants[1] = RoleGrant(keccak256("WITHDRAW_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - grants[2] = RoleGrant(keccak256("CERTIFY_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - grants[3] = RoleGrant(keccak256("CONFISCATE_SHARES_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - grants[4] = RoleGrant(keccak256("CONFISCATE_RECEIPT_ADMIN"), GRANTEE_TOKEN_OWNER_SAFE); - - // Service EOA provisioned at blocks 41797262, 41797281, 41797297. - grants[5] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_SERVICE_1C66); - grants[6] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_SERVICE_1C66); - grants[7] = RoleGrant(keccak256("CERTIFY"), GRANTEE_SERVICE_1C66); - - // Safe later granted itself the corresponding action roles (blocks - // 42704120, 42704140, 44076075) for direct operational use. - grants[8] = RoleGrant(keccak256("DEPOSIT"), GRANTEE_TOKEN_OWNER_SAFE); - grants[9] = RoleGrant(keccak256("WITHDRAW"), GRANTEE_TOKEN_OWNER_SAFE); - grants[10] = RoleGrant(keccak256("CERTIFY"), GRANTEE_TOKEN_OWNER_SAFE); - } -} diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index 04599e97..9ef75410 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -165,15 +165,6 @@ library LibTokenInvariants { /// https://basescan.org/address/0x78c31580c97101694c70022c83d570150c11e935 address internal constant SGOV_WRAPPED_TOKEN_VAULT = address(0x78c31580c97101694C70022c83D570150c11e935); - /// @notice The single authoriser every production receipt vault is gated - /// by, as a token-side invariant. Pinned here as the expected value for - /// `assertUniformAuthoriser`; updated post-swap when the receipt vaults - /// are rewired onto a new authoriser clone. - /// @dev Read from `authorizer()` on the live vaults on Base. A vault - /// reporting any other authoriser is gated by a different RBAC contract - /// than the rest of the system and trips the invariant. - address internal constant STOX_PROD_AUTHORISER = address(0x35f9fA9d80aAF2B0fB27f0FF015641B3408d7456); - /// @notice Returns the 13 production receipt vault addresses on Base, in /// the order they were deployed. Provided so consumers (e.g. invariant /// assertions, migration scripts) can iterate without hardcoding the diff --git a/test/src/lib/LibAuthoriserInvariants.t.sol b/test/src/lib/LibAuthoriserInvariants.t.sol new file mode 100644 index 00000000..6cb7b3d6 --- /dev/null +++ b/test/src/lib/LibAuthoriserInvariants.t.sol @@ -0,0 +1,35 @@ +// 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 {LibAuthoriserInvariants} from "../../../src/lib/LibAuthoriserInvariants.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; + +/// @title LibAuthoriserInvariantsTest +/// @notice Fork tests pinning the live ST0x authoriser's role-grant map +/// against the constants in `LibAuthoriserInvariants`. The positive case +/// runs the lib's no-arg `assertAll()`, which iterates `expectedGrants()` +/// and asserts every pair against the live authoriser pinned at +/// `STOX_PROD_AUTHORISER`. Any drift (a pin missing on-chain, or an +/// off-chain pin the lib doesn't know about) surfaces as +/// `ExpectedGrantMissing` here. +/// @dev Uses an unpinned Base head fork (same precedent as the other +/// prod-state drift detectors in this repo). Pinning would freeze the +/// invariant assertions against a stale snapshot and let new drift slip +/// through unnoticed. +contract LibAuthoriserInvariantsTest is Test { + /// @notice Selects the Base fork at chain head — deliberately unpinned. + /// Live drift detector; see contract-level rationale. + function selectBaseFork() internal { + vm.createSelectFork(LibRainDeploy.BASE); + } + + /// @notice The live authoriser pinned at + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER` holds every + /// `expectedGrants()` pair. Passes against the live chain state. + function testAssertAllPasses() external { + selectBaseFork(); + LibAuthoriserInvariants.assertAll(); + } +} diff --git a/test/src/lib/LibProdAuthoriser.t.sol b/test/src/lib/LibProdAuthoriser.t.sol deleted file mode 100644 index dfaab20f..00000000 --- a/test/src/lib/LibProdAuthoriser.t.sol +++ /dev/null @@ -1,39 +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 {LibProdAuthoriser} from "../../../src/lib/LibProdAuthoriser.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; - -/// @title LibProdAuthoriserTest -/// @notice Fork tests pinning the live ST0x authoriser's role-grant map -/// against the constants in `LibProdAuthoriser`. Iterates -/// `expectedGrants()` and asserts `hasRole(role, grantee) == true` for -/// every pair; any drift (a pin missing on-chain, or an off-chain pin -/// the lib doesn't know about) surfaces here. -/// @dev Uses an unpinned Base head fork (same precedent as the other -/// prod-state drift detectors in this repo). Pinning would freeze the -/// invariant assertions against a stale snapshot and let new drift slip -/// through unnoticed. -contract LibProdAuthoriserTest is Test { - /// @notice Selects the Base fork at chain head — deliberately unpinned. - /// Live drift detector; see contract-level rationale. - function selectBaseFork() internal { - vm.createSelectFork(LibRainDeploy.BASE); - } - - /// @notice Every pinned `(role, grantee)` pair in `expectedGrants()` is - /// held on the live authoriser. Passes against the live chain state. - function testExpectedGrantsAllPresent() external { - selectBaseFork(); - IAccessControl authoriser = IAccessControl(LibProdAuthoriser.STOX_PROD_AUTHORISER); - LibProdAuthoriser.RoleGrant[] memory grants = LibProdAuthoriser.expectedGrants(); - for (uint256 i = 0; i < grants.length; i++) { - assertTrue( - authoriser.hasRole(grants[i].role, grants[i].grantee), "expected grant missing on live authoriser" - ); - } - } -} diff --git a/test/src/lib/LibTokenInvariants.t.sol b/test/src/lib/LibTokenInvariants.t.sol index f361392e..b2cdc4d2 100644 --- a/test/src/lib/LibTokenInvariants.t.sol +++ b/test/src/lib/LibTokenInvariants.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibAuthoriserInvariants} from "../../../src/lib/LibAuthoriserInvariants.sol"; import {LibTokenInvariants, IOwnable, ReceiptVaultOwnerMismatch} from "../../../src/lib/LibTokenInvariants.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; import {LibTokenInvariantsHarness} from "./LibTokenInvariantsHarness.sol"; @@ -15,7 +16,7 @@ import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; /// /// Both uniformity invariants currently hold on-chain (every vault is /// owned by `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` and reports the pinned -/// `LibTokenInvariants.STOX_PROD_AUTHORISER`), so the positive +/// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`), so the positive /// cases pass against the live Base fork. The inverted ownership-drift /// case is also exercised here for full error-path coverage. /// @dev Uses an unpinned Base head fork (same precedent as the other @@ -43,11 +44,11 @@ contract LibTokenInvariantsTest is Test { } /// @notice Every production receipt vault reports - /// `LibTokenInvariants.STOX_PROD_AUTHORISER`. Passes against + /// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`. Passes against /// the live chain state: vault authoriser is uniform. function testProdReceiptVaultsShareUniformAuthoriser() external { selectBaseFork(); - LibTokenInvariants.assertUniformAuthoriser(LibTokenInvariants.STOX_PROD_AUTHORISER); + LibTokenInvariants.assertUniformAuthoriser(LibAuthoriserInvariants.STOX_PROD_AUTHORISER); } /// @notice Token-side ownership drift trips `ReceiptVaultOwnerMismatch`. From fda62213b02201821d5dd68afc6daad13ec19481 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 25 Jun 2026 11:56:41 +0000 Subject: [PATCH 11/11] fix(restack): drop duplicated inline IOwnable/IAuthorisable from LibTokenInvariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restack merge resolution took #201's pre-extraction LibTokenInvariants, which re-declared IOwnable/IAuthorisable inline — duplicating the canonical src/interface/ versions. Use main's LibTokenInvariants (which imports them) minus #201's only change (STOX_PROD_AUTHORISER, now owned by LibAuthoriserInvariants). --- src/lib/LibTokenInvariants.sol | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/src/lib/LibTokenInvariants.sol b/src/lib/LibTokenInvariants.sol index 9ef75410..cdec7821 100644 --- a/src/lib/LibTokenInvariants.sol +++ b/src/lib/LibTokenInvariants.sol @@ -2,26 +2,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -/// @notice Minimal `Ownable`-like surface used by ST0x receipt vaults. -/// Every production receipt vault exposes `owner()`; this library only -/// needs the getter, not the transfer/renounce mutators. Declared inline -/// here so the token-invariant bundle owns its only external surface -/// rather than depending on a richer token-side interface that could drift. -interface IOwnable { - /// @notice The current owner of the contract. - /// @return The owner address. - function owner() external view returns (address); -} - -/// @notice Minimal authoriser-getter surface exposed by ST0x receipt -/// vaults. Declared inline (returning `address`) rather than importing -/// the upstream `IAuthorizableV1` so this library owns its only external -/// surface and doesn't carry the upstream's richer return type. -interface IAuthorisable { - /// @notice The authoriser contract gating restricted vault operations. - /// @return The authoriser address. - function authorizer() external view returns (address); -} +import {IOwnable} from "../interface/IOwnable.sol"; +import {IAuthorisable} from "../interface/IAuthorisable.sol"; /// @notice A production receipt vault's `owner()` does not match the owner /// the uniform-ownership invariant expected every vault to share. Surfaces