Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0b59da4
feat(safe): receipt vault V3 upgrade script + shadow-fork verification
May 28, 2026
d2f4e1a
refactor(safe): point V3 upgrade at LibBeaconInvariants
May 29, 2026
6aa5d29
feat(safe): receipt vault V4 upgrade + authoriser swap script (placeh…
May 31, 2026
a74402e
refactor(safe): drop V4AuthoriserCloneDisallowedGrantHeld pre-flight …
Jun 1, 2026
634c2b3
refactor(safe): rewire V4 upgrade script to LibAuthoriserInvariants +…
Jun 1, 2026
b0d45fb
fix(tests): rebase-fix rain-deploy 0.1.3→0.1.4 + LibProdDeployV3→V4
Jul 5, 2026
97f5d82
refactor(tests): swap placeholder-posture guards for migration-invariant
Jul 7, 2026
b6d155c
test(safe): pin V3 prod deployment state (DRAFT)
May 28, 2026
10e88b5
test(safe): pin V4 prod deployment state + post-swap authoriser state…
May 31, 2026
a9f8771
refactor(safe): drop disallowedGrants check from V4 prod-state pin
Jun 1, 2026
17f2a8f
refactor(safe): rewire V4 prod-state pin to LibAuthoriserInvariants +…
Jun 1, 2026
cabb48f
fix(tests): rebase-fix rain-deploy 0.1.3→0.1.4 import in V4 prod-stat…
Jul 5, 2026
39802b0
refactor(tests): V4 post-swap pin uses migration-invariant + rename
Jul 7, 2026
779b7a0
refactor(tests): gate cross-network V4 codehash checks by deadline too
Jul 7, 2026
007b7e1
feat(invariants): migration-window the orchestrator's authoriser leg …
Jul 8, 2026
a7ad93e
Merge remote-tracking branch 'origin/main' into work-197
thedavidmeister Jul 9, 2026
c29fe17
test(upgrade): exercise V4 upgrade script guards + swap-leg gating
thedavidmeister Jul 9, 2026
cedbe0f
fix(test): assert V4 impl against the live Base deploy, not current s…
thedavidmeister Jul 9, 2026
3c69a12
feat(upgrade): pre-flight verifies all 7 admin roles on the Safe
thedavidmeister Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/run-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ on:
# too, but dispatching via run-script.yaml is DRY-RUN ONLY (no
# `--broadcast`) — useful as a pre-flight smoke test.
- 20260619-deploy-v4-authoriser-clone
- 20260623-upgrade-receipt-vaults-to-v4
sig:
description: 'Entrypoint to dispatch (default: run())'
required: true
Expand Down
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,52 @@ The `multisig-artifact` GitHub workflow runs the dry-run on `workflow_dispatch`
dependencies, uploading `out/*.json` as a build artifact so reviewers can
download the bundle directly from the run.

### Beacon ownership migration

`script/MigrateBeaconOwners.s.sol` transfers ownership of the three production
V1 beacons that live tokens use — the receipt beacon, the receipt vault beacon,
and the wrapped token vault beacon — from the rainlang.eth EOA to the
`STOX_TOKEN_OWNER_SAFE`, so beacon upgrades route through the multisig. Unlike
the threshold migration this is a direct EOA broadcast (an `Ownable` beacon
transfers ownership by the current owner calling `transferOwnership`), so the
script emits no Tx Builder JSON — the output is the on-chain `transferOwnership`
transactions. It still runs the full operational treatment: a pre-flight
`assertBeaconInvariants` against the expected EOA-owned state, the broadcast
transfers, a post-state `assertBeaconInvariants` against the Safe-owned state,
and an n+1 reversibility check per beacon (an idempotent
`upgradeTo(currentImpl)` routed through the Safe's `execTransaction`, proving
the Safe can act on each beacon after the migration).

```shell
forge script script/MigrateBeaconOwners.s.sol \
--rpc-url base --broadcast --private-key <EOA key>
```

### Receipt vault V3 upgrade

`script/UpgradeReceiptVaultToV3.s.sol` authors the Safe transaction that points
`STOX_RECEIPT_VAULT_BEACON_V1` at the V3 receipt vault implementation (corporate
actions). After execution every live receipt vault routes corporate-action
selectors into the V3 facet via fallback delegatecall. The beacon must already
be Safe-owned (run the beacon ownership migration first) and the V3
implementation must already be deployed at its deterministic Zoltu address with
the audited codehash. The script runs `assertAll(safe)` +
`assertBeaconInvariants(beacon, safe, V1 impl)` as pre-flight, simulates the
`upgradeTo`, asserts the post-state (`beacon -> V3 impl`, Safe unchanged), emits
the Tx Builder JSON to `out/v3-upgrade.json`, prints the `SafeTxHash`, and
proves n+1 reversibility back to the V1 implementation.

```shell
BASE_RPC_URL=https://base-rpc.publicnode.com \
forge script script/UpgradeReceiptVaultToV3.s.sol --rpc-url base
```

The post-upgrade behaviour of live tokens is verified by the shadow-fork suite
`test/src/concrete/upgrade/V3UpgradeShadowFork.t.sol`, which applies the upgrade
to a Base head fork and exercises corporate-action fallback routing,
backwards-compatible reads, authoriser and receipt wiring, and certification
against a real on-chain receipt vault.

## License

LicenseRef-DCL-1.0 (DecentraLicense). REUSE-compliant.
309 changes: 309 additions & 0 deletions script/20260623-upgrade-receipt-vaults-to-v4.s.sol

Large diffs are not rendered by default.

32 changes: 28 additions & 4 deletions src/lib/LibInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity ^0.8.25;

import {IGnosisSafe} from "../interface/IGnosisSafe.sol";
import {LibAuthoriserInvariants} from "./LibAuthoriserInvariants.sol";
import {LibProdDeployV4} from "./LibProdDeployV4.sol";
import {LibSafeInvariants} from "./LibSafeInvariants.sol";
import {LibTokenInvariants} from "./LibTokenInvariants.sol";

Expand Down Expand Up @@ -31,25 +32,48 @@ library LibInvariants {
/// 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 `LibSafeInvariants`-pinned defaults.
///
/// The authoriser leg is migration-window gated for the V4 swap:
/// every vault's `authorizer()` may be the V3 authoriser
/// (`LibAuthoriserInvariants.STOX_PROD_AUTHORISER`) or the V4 clone
/// (`LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE`) until
/// `LibProdDeployV4.V4_SWAP_DEADLINE`; only the V4 clone after. This
/// keeps the bundle green across the swap with no post-execution lib
/// repoint: before the swap the pre-state matches, after the swap the
/// post-state matches, and past the deadline an un-run swap red-lines
/// cron. `LibAuthoriserInvariants.assertAll()` continues to validate
/// the V3 clone's own impl pin + grant map — properties of that
/// contract which stay true after the swap (the swap does not revoke
/// anything on the old clone).
/// @param safe The Safe to validate against the pinned current truth.
function assertAll(IGnosisSafe safe) internal view {
LibSafeInvariants.assertAll(safe);
LibTokenInvariants.assertAll(address(safe), LibAuthoriserInvariants.STOX_PROD_AUTHORISER);
LibTokenInvariants.assertUniformOwnership(address(safe));
LibTokenInvariants.assertUniformAuthoriserMigration(
LibAuthoriserInvariants.STOX_PROD_AUTHORISER,
LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE,
LibProdDeployV4.V4_SWAP_DEADLINE
);
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 and authoriser-
/// side legs use the pinned current-truth defaults from
/// `LibAuthoriserInvariants.STOX_PROD_AUTHORISER`.
/// side legs match the no-arg overload, including the V4 swap
/// migration window on the authoriser leg.
/// @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), LibAuthoriserInvariants.STOX_PROD_AUTHORISER);
LibTokenInvariants.assertUniformOwnership(address(safe));
LibTokenInvariants.assertUniformAuthoriserMigration(
LibAuthoriserInvariants.STOX_PROD_AUTHORISER,
LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE,
LibProdDeployV4.V4_SWAP_DEADLINE
);
LibAuthoriserInvariants.assertAll();
}
}
14 changes: 14 additions & 0 deletions src/lib/LibProdDeployV4.sol
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,20 @@ library LibProdDeployV4 {
bytes32 constant STOX_PROD_AUTHORISER_V4_CLONE_CODEHASH =
0x2089950d3cc1112dd66a58adcfadeadc490b50053ac67be8bc676b4a2dcd1717;

/// @notice Unix timestamp past which the V4 authoriser swap must have
/// landed on Base: every production receipt vault's `authorizer()`
/// must report `STOX_PROD_AUTHORISER_V4_CLONE` (not the V3 authoriser)
/// from this moment on. `2026-11-01T00:00:00Z`.
///
/// Consumed by the `LibMigrationInvariant`-gated authoriser leg in
/// `LibInvariants.assertAll` and by the V4 prod-state pins: before the
/// deadline both the V3 authoriser and the V4 clone are accepted, so
/// the invariant merges alongside the swap script instead of waiting
/// for execution; after the deadline only the V4 clone passes and cron
/// red-lines until the swap runs, the deadline is extended, or the
/// migration is explicitly abandoned.
uint256 constant V4_SWAP_DEADLINE = 1_793_491_200;

// =========================================================================
// Per-release creation + runtime bytecode (frozen historicals).
//
Expand Down
25 changes: 25 additions & 0 deletions src/lib/LibTokenInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pragma solidity ^0.8.25;

import {IOwnable} from "../interface/IOwnable.sol";
import {IAuthorisable} from "../interface/IAuthorisable.sol";
import {LibMigrationInvariant} from "./LibMigrationInvariant.sol";

/// @notice A production receipt vault's `owner()` does not match the owner
/// the uniform-ownership invariant expected every vault to share. Surfaces
Expand Down Expand Up @@ -274,6 +275,30 @@ library LibTokenInvariants {
}
}

/// @notice Migration-window variant of `assertUniformAuthoriser`:
/// every production receipt vault's `authorizer()` must be `pre` OR
/// `post` before `deadline`, and exactly `post` at/after it. Lets the
/// authoriser-swap invariant merge alongside the swap script instead
/// of waiting for on-chain execution — both sides of the transition
/// are cron-covered, and a swap left un-run past the deadline
/// red-lines via `MigrationDeadlinePassed`.
/// @dev Each vault is asserted independently, so a half-landed swap
/// (some vaults on `pre`, some on `post`) passes before the deadline
/// — the bundle is atomic per Safe execution, but this leg does not
/// assume that. Any third value trips `MigrationStateDrift`
/// immediately regardless of the deadline.
/// @param pre The accepted authoriser before the swap runs.
/// @param post The accepted authoriser after the swap runs.
/// @param deadline Unix timestamp past which only `post` is accepted.
function assertUniformAuthoriserMigration(address pre, address post, uint256 deadline) internal view {
address[] memory vaults = productionReceiptVaults();
for (uint256 i = 0; i < vaults.length; i++) {
LibMigrationInvariant.assertMigration(
"receiptVault.authorizer()", IAuthorisable(vaults[i]).authorizer(), pre, post, deadline
);
}
}

/// @notice Full token-side invariant bundle: every production receipt
/// vault reports the supplied Safe as its `owner()` AND the supplied
/// authoriser as its `authorizer()`. Pre-flight / post-state hook for
Expand Down
161 changes: 161 additions & 0 deletions test/script/20260623-upgrade-receipt-vaults-to-v4.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity =0.8.25;

import {Test} from "forge-std-1.16.1/src/Test.sol";
import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol";

import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol";
import {IAuthorizableV1} from "rain-vats-0.1.6/src/interface/IAuthorizableV1.sol";

import {LibTokenInvariants} from "../../src/lib/LibTokenInvariants.sol";
import {LibAuthoriserInvariants} from "../../src/lib/LibAuthoriserInvariants.sol";
import {LibProdDeployV4} from "../../src/lib/LibProdDeployV4.sol";
import {LibProdDeployV1} from "../../src/lib/LibProdDeployV1.sol";
import {LibSafeInvariants} from "../../src/lib/LibSafeInvariants.sol";
import {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol";
import {IUpgradeableBeacon} from "../../src/lib/LibSafeOps.sol";
import {
UpgradeReceiptVaultsToV4,
V4ImplementationNotDeployed,
V4CodehashMismatch,
V4AuthoriserCloneNotPinned,
VaultAuthoriserMismatchPostUpgrade
} from "../../script/20260623-upgrade-receipt-vaults-to-v4.s.sol";
import {UpgradeReceiptVaultsToV4Harness} from "./UpgradeReceiptVaultsToV4Harness.sol";

/// @title UpgradeReceiptVaultsToV4Test
/// @notice Live-fork pin of the vault-authoriser transition executed by
/// `script/20260623-upgrade-receipt-vaults-to-v4.s.sol`, via the same
/// migration-window leg `LibInvariants.assertAll` composes:
/// `LibTokenInvariants.assertUniformAuthoriserMigration(V3, V4 clone,
/// V4_SWAP_DEADLINE)`. Before the deadline each vault may report the V3
/// authoriser or the V4 clone; after it only the V4 clone passes and cron
/// red-lines until the swap runs, the deadline is extended, or the
/// migration is explicitly abandoned.
///
/// @dev While the V4 clone pin is still `address(0)` (clone-address PR not
/// yet landed), the window collapses to "must be V3 authoriser" — every
/// live vault reports the V3 authoriser and passes. Once the clone is
/// pinned + the swap has run on Base, the live reads flip to the V4 clone
/// and the same test still passes with no code change.
///
/// Uses an unpinned Base head fork so `block.timestamp` is real. Pinning a
/// block would freeze the deadline check to whichever timestamp the pinned
/// block carried, which is exactly the wrong behaviour for a deadline-gated
/// invariant.
contract UpgradeReceiptVaultsToV4Test is Test {
/// @notice Every production receipt vault's `authorizer()` is within
/// the V4 swap migration window. Base-only — no other network carries
/// live production receipt vaults.
function testVaultAuthoriserInMigrationWindow() external {
vm.createSelectFork(LibRainDeploy.BASE);
LibTokenInvariants.assertUniformAuthoriserMigration(
LibAuthoriserInvariants.STOX_PROD_AUTHORISER,
LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE,
LibProdDeployV4.V4_SWAP_DEADLINE
);
}

/// @notice The receipt-vault beacon whose implementation the script
/// upgrades.
address internal constant BEACON = LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1;

/// @notice The V4 receipt-vault implementation the script upgrades the
/// beacon to (placeholder Zoltu address until the patched build lands).
address internal constant V4_IMPL = LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1;

/// @notice Fork Base and simulate PR #196 (transfer the receipt-vault
/// beacon from the deploy EOA to the Safe) so the script's beacon
/// pre-flight (`assertBeaconInvariants`, Safe-owned + at V1) passes and
/// `run()` reaches the V4 impl + clone checks.
function _forkAndMigrateBeaconOwnership() internal {
vm.createSelectFork(LibRainDeploy.BASE);
vm.prank(LibProdDeployV1.BEACON_INITIAL_OWNER);
Ownable(BEACON).transferOwnership(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE);
}

/// @notice `run()` pre-flight reverts `V4ImplementationNotDeployed` when
/// the V4 receipt-vault impl has no code at its Zoltu address. The impl is
/// already live on Base, so the undeployed state is forced with
/// `vm.etch(V4_IMPL, "")`. Also pins pre-flight ordering: the Safe + beacon
/// invariants pass first (real live state + simulated #196), so the revert
/// is specifically the V4-impl gate, not an earlier one.
function testRunRevertsWhenV4ImplNotDeployed() external {
_forkAndMigrateBeaconOwnership();
vm.etch(V4_IMPL, "");
UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4();
vm.expectRevert(abi.encodeWithSelector(V4ImplementationNotDeployed.selector, V4_IMPL));
upgradeScript.run();
}

/// @notice `run()` pre-flight reverts `V4CodehashMismatch` when code exists
/// at the V4 impl address but its codehash differs from the pinned V4
/// codehash — the on-chain bytecode is not the audited V4 build.
function testRunRevertsWhenV4CodehashMismatches() external {
_forkAndMigrateBeaconOwnership();
bytes memory bogus = hex"60016000526001601ff3";
vm.etch(V4_IMPL, bogus);
UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4();
vm.expectRevert(
abi.encodeWithSelector(
V4CodehashMismatch.selector,
V4_IMPL,
LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1,
keccak256(bogus)
)
);
upgradeScript.run();
}

/// @notice The audited V4 impl is already deployed on Base at `V4_IMPL`
/// with the pinned codehash, so the impl-side pre-flight passes against the
/// live fork (no planting needed — planting the current source would carry
/// a later release's bytecode, not the pinned `0.1.1` build) and `run()`
/// reaches the next forcing function: `V4AuthoriserCloneNotPinned` (the
/// clone pin is still `address(0)`). The codehash assert doubles as a
/// prod-state pin: the on-chain V4 impl must match the lib's pinned
/// codehash.
function testRunRevertsWhenV4AuthoriserCloneNotPinned() external {
_forkAndMigrateBeaconOwnership();
assertEq(
V4_IMPL.codehash,
LibProdDeployV4.STOX_RECEIPT_VAULT_CODEHASH_0_1_1,
"live V4 impl codehash != pinned codehash"
);
UpgradeReceiptVaultsToV4 upgradeScript = new UpgradeReceiptVaultsToV4();
vm.expectRevert(V4AuthoriserCloneNotPinned.selector);
upgradeScript.run();
}

/// @notice `_assertPostState` reverts `VaultAuthoriserMismatchPostUpgrade`
/// when a production vault still reports a non-V4-clone authoriser after
/// the beacon leg. Drives the beacon to V4 (so the beacon post-state
/// passes) but leaves authorisers un-swapped, so the per-vault loop trips
/// on vault 0. Exercises the post-state guard the placeholder clone pin
/// otherwise keeps `run()` from ever reaching.
function testAssertPostStateRevertsWhenVaultNotSwapped() external {
_forkAndMigrateBeaconOwnership();
deployCodeTo("src/concrete/StoxReceiptVault.sol:StoxReceiptVault", V4_IMPL);
deployCodeTo(
"src/concrete/StoxCorporateActionsFacet.sol:StoxCorporateActionsFacet",
LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_0_1_1
);
vm.prank(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE);
IUpgradeableBeacon(BEACON).upgradeTo(V4_IMPL);

UpgradeReceiptVaultsToV4Harness harness = new UpgradeReceiptVaultsToV4Harness();
IGnosisSafe safe = IGnosisSafe(LibSafeInvariants.STOX_TOKEN_OWNER_SAFE);
address[] memory vaults = LibTokenInvariants.productionReceiptVaults();
address firstAuth = address(IAuthorizableV1(vaults[0]).authorizer());
vm.expectRevert(
abi.encodeWithSelector(
VaultAuthoriserMismatchPostUpgrade.selector,
vaults[0],
LibProdDeployV4.STOX_PROD_AUTHORISER_V4_CLONE,
firstAuth
)
);
harness.callAssertPostState(safe, vaults);
}
}
20 changes: 20 additions & 0 deletions test/script/UpgradeReceiptVaultsToV4Harness.sol
Original file line number Diff line number Diff line change
@@ -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 {IGnosisSafe} from "../../src/interface/IGnosisSafe.sol";
import {UpgradeReceiptVaultsToV4} from "../../script/20260623-upgrade-receipt-vaults-to-v4.s.sol";

/// @title UpgradeReceiptVaultsToV4Harness
/// @notice Subclass of the upgrade script that exposes its `internal`
/// post-state assertion as `external` so `vm.expectRevert` can intercept the
/// typed `VaultAuthoriserMismatchPostUpgrade` it raises. The pre-flight guards
/// are exercised via `run()` directly in the tests (they revert before any
/// bundle is built); only the post-state — which `run()` reaches only after
/// the still-placeholder clone pin is hydrated — needs this seam to be driven
/// against a deliberately-malformed (un-swapped) state.
contract UpgradeReceiptVaultsToV4Harness is UpgradeReceiptVaultsToV4 {
function callAssertPostState(IGnosisSafe safe, address[] memory vaults) external view {
_assertPostState(safe, vaults);
}
}
Loading
Loading