Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 52 additions & 0 deletions src/lib/LibBeaconInvariants.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
pragma solidity ^0.8.25;

import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol";
import {LibProdBeaconsBase} from "./LibProdBeaconsBase.sol";
import {LibProdBeaconsEthereum} from "./LibProdBeaconsEthereum.sol";
import {LibSafeInvariants} from "./LibSafeInvariants.sol";

/// @notice Minimal `Ownable`-like surface used to read a beacon's owner.
Expand Down Expand Up @@ -63,6 +65,13 @@ error BeaconImplementationMismatch(address beacon, address expected, address act
/// @param implementation The implementation address that has no code.
error BeaconImplNotDeployed(address beacon, address implementation);

/// @notice No in-use production beacon set is pinned for the active chain.
/// Deliberately a typed revert rather than a silent fallback to another
/// chain's beacons: asserting against beacons production doesn't run on is
/// exactly the dead-state pinning this map exists to prevent.
/// @param chainId The chain id with no pinned in-use beacon set.
error UnsupportedChainForProdBeacons(uint256 chainId);

/// @title LibBeaconInvariants
/// @notice Reusable invariant assertions for an OpenZeppelin
/// `UpgradeableBeacon`. The single public assertion either returns silently
Expand Down Expand Up @@ -168,4 +177,47 @@ library LibBeaconInvariants {
revert BeaconImplNotDeployed(beacon, actualImpl);
}
}

/// @notice The three production beacons IN USE on the active chain, in a
/// fixed order (receipt, receipt vault, wrapped token vault). Beacon
/// addresses are per-chain deploy artifacts that never change once a
/// chain's production tokens point at them — only the implementations they
/// serve are upgraded — so "which beacons is production running on" is
/// per-chain pinned state. Each chain's set lives in its own lib
/// (`LibProdBeaconsBase` / `LibProdBeaconsEthereum`, same shape and index
/// order); this map only dispatches by chain id.
/// @param chainId The active chain id (`block.chainid`).
/// @return The chain's three in-use beacon addresses.
function prodBeaconsForChainId(uint256 chainId) internal view returns (address[3] memory) {
if (chainId == LibSafeInvariants.BASE_CHAIN_ID) {
return LibProdBeaconsBase.beacons();
}
if (chainId == LibSafeInvariants.ETHEREUM_CHAIN_ID) {
return LibProdBeaconsEthereum.beacons();
}
revert UnsupportedChainForProdBeacons(chainId);
}

/// @notice Assert the active chain's three IN-USE production beacons are
/// deployed and owned by THAT chain's token-owner Safe. This is the
/// ownership invariant that matters operationally: whoever owns an in-use
/// beacon can repoint every production vault proxy on the chain, so each
/// chain's live beacons must be held by its Safe — no EOA, no other
/// chain's Safe. Where the beacons POINT is deliberately not asserted
/// here; implementation parity across chains is the cross-chain parity
/// pin's concern.
/// @param chainId The active chain id (`block.chainid`).
function assertProdBeaconsOwnedByChainSafe(uint256 chainId) internal view {
address[3] memory beacons = prodBeaconsForChainId(chainId);
address expectedOwner = LibSafeInvariants.safeForChainId(chainId);
for (uint256 i = 0; i < beacons.length; i++) {
if (beacons[i].code.length == 0) {
revert BeaconNotDeployed(beacons[i]);
}
address actualOwner = IOwnable(beacons[i]).owner();
if (actualOwner != expectedOwner) {
revert BeaconOwnerMismatch(beacons[i], expectedOwner, actualOwner);
}
}
}
}
52 changes: 52 additions & 0 deletions src/lib/LibProdBeaconsBase.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity ^0.8.25;

import {LibProdDeployV1} from "./LibProdDeployV1.sol";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import {LibProdDeployV4} from "../generated/LibProdDeployV4.sol";

/// @title LibProdBeaconsBase
/// @notice The three ST0x production beacons on **Base** and the
/// implementations they point at — the Base counterpart of
/// `LibProdBeaconsEthereum`, same shape and index order so per-chain
/// consumers dispatch to one lib per chain instead of hand-assembling
/// either side.
/// @dev Base's production tokens run on the **V1-generation** beacon
/// addresses: deployed at V1, retained through every implementation upgrade
/// since (a beacon address is a per-chain deploy artifact that never
/// changes; only the implementation it serves is upgraded). Later-generation
/// beacon deploys on Base — the deterministic 0.1.1 Zoltu set among them —
/// exist on-chain but were never adopted by production and are deliberately
/// not represented here.
///
/// No pasted addresses: the beacons reference the hand-pinned V1 constants
/// in `LibProdDeployV1`, and the implementations reference the generated
/// `0_1_1` impl pins (deterministic Zoltu deploys, the SAME addresses on
/// every chain) — the V4 upgrade pointed Base's V1-address beacons at those
/// 0.1.1 implementations.
library LibProdBeaconsBase {
/// @notice The three production beacons, in a fixed order (receipt,
/// receipt vault, wrapped token vault) — index-aligned with
/// `implementations()` and with `LibProdBeaconsEthereum.beacons()`.
/// @return The three Base beacon addresses.
function beacons() internal pure returns (address[3] memory) {
return [
LibProdDeployV1.STOX_RECEIPT_BEACON_V1,
LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1,
LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1
];
}

/// @notice The implementation each beacon points at, index-aligned with
/// `beacons()`. Referenced from the generated `0_1_1` impl pins — the
/// same deterministic addresses `LibProdBeaconsEthereum.implementations()`
/// resolves, because implementation parity across chains is the goal.
/// @return The three implementation addresses.
function implementations() internal pure returns (address[3] memory) {
return [
LibProdDeployV4.STOX_RECEIPT_0_1_1,
LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1,
LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1
];
}
}
49 changes: 23 additions & 26 deletions test/src/concrete/deploy/StoxProdV4.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {Test} from "forge-std-1.16.1/src/Test.sol";
import {LibProdDeployV4} from "../../../../src/generated/LibProdDeployV4.sol";
import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol";
import {LibStoxDeployNetworks} from "../../../../src/lib/LibStoxDeployNetworks.sol";
import {LibBeaconInvariants} from "../../../../src/lib/LibBeaconInvariants.sol";
import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol";
import {Ownable} from "@openzeppelin-contracts-5.6.1/access/Ownable.sol";
import {ST0xOrchestratorBeaconSetDeployer} from "../../../../src/concrete/deploy/ST0xOrchestratorBeaconSetDeployer.sol";
Expand Down Expand Up @@ -40,9 +41,16 @@ contract StoxProdV4Test is Test {
/// addresses with the pinned codehashes; that the wrapped-token-vault beacon
/// points at the 0.1.1 vault implementation; and that the
/// offchain-asset-receipt-vault beacon-set deployer's two beacons point at
/// the 0.1.1 receipt and receipt vault implementations. All three beacons are
/// still held by the beacon initial owner (pre-migration deploy state). This
/// is the exact set shipped to Ethereum mainnet, and a subset of Base.
/// the 0.1.1 receipt and receipt vault implementations. This is the exact
/// set shipped to Ethereum mainnet, and a subset of Base.
///
/// Deliberately says nothing about beacon OWNERSHIP: that is live
/// operational state, and it only matters for the beacons production
/// tokens actually run on — asserted per chain via
/// `LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe` in the network
/// tests. On Base the 0.1.1-address beacons checked here are an unadopted
/// deploy artifact whose owner is irrelevant; on Ethereum they ARE the
/// in-use beacons and the per-chain assert covers them.
function checkProd_0_1_1OnChain() internal view {
assertTrue(LibProdDeployV4.STOX_RECEIPT_0_1_1.code.length > 0, "V4 StoxReceipt not deployed");
assertEq(LibProdDeployV4.STOX_RECEIPT_0_1_1.codehash, LibProdDeployV4.STOX_RECEIPT_CODEHASH_0_1_1);
Expand Down Expand Up @@ -150,26 +158,18 @@ contract StoxProdV4Test is Test {
LibProdDeployV4.STOX_CORPORATE_ACTIONS_FACET_RUNTIME_CODE_0_1_1
);

// The wrapped-token-vault beacon points at the 0.1.1 vault implementation
// and is still held by the beacon initial owner (rainlang.eth), which is
// the deploy-time state before ownership migration to the ST0x
// token-owner Safe.
// The wrapped-token-vault beacon points at the 0.1.1 vault
// implementation — constructor wiring of the deploy artifact.
assertEq(
IBeacon(LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_0_1_1).implementation(),
LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_0_1_1,
"V4 beacon implementation mismatch"
);
assertEq(
Ownable(LibProdDeployV4.STOX_WRAPPED_TOKEN_VAULT_BEACON_0_1_1).owner(),
LibProdDeployV4.BEACON_INITIAL_OWNER,
"V4 beacon owner mismatch"
);

// The offchain-asset-receipt-vault beacon-set deployer creates two
// beacons in its constructor: the receipt beacon points at the 0.1.1
// receipt implementation and the offchain-asset-receipt-vault beacon
// points at the 0.1.1 receipt vault implementation, both held by the
// beacon initial owner.
// points at the 0.1.1 receipt vault implementation.
IOffchainAssetReceiptVaultBeaconSetDeployerV2 oarvDeployer = IOffchainAssetReceiptVaultBeaconSetDeployerV2(
LibProdDeployV4.STOX_OFFCHAIN_ASSET_RECEIPT_VAULT_BEACON_SET_DEPLOYER_0_1_1
);
Expand All @@ -180,23 +180,13 @@ contract StoxProdV4Test is Test {
LibProdDeployV4.STOX_RECEIPT_0_1_1,
"V4 OARV receipt beacon implementation mismatch"
);
assertEq(
Ownable(address(receiptBeacon)).owner(),
LibProdDeployV4.BEACON_INITIAL_OWNER,
"V4 OARV receipt beacon owner mismatch"
);

IBeacon vaultBeacon = oarvDeployer.iOffchainAssetReceiptVaultBeacon();
assertEq(
vaultBeacon.implementation(),
LibProdDeployV4.STOX_RECEIPT_VAULT_0_1_1,
"V4 OARV vault beacon implementation mismatch"
);
assertEq(
Ownable(address(vaultBeacon)).owner(),
LibProdDeployV4.BEACON_INITIAL_OWNER,
"V4 OARV vault beacon owner mismatch"
);
}

/// Asserts the full accumulated V4 set carried by Base: the audited 0.1.1 set
Expand Down Expand Up @@ -369,17 +359,24 @@ contract StoxProdV4Test is Test {
}

/// The full accumulated V4 set MUST be deployed on Base with the expected
/// codehashes.
/// codehashes, and Base's IN-USE production beacons (the V1-generation
/// addresses — NOT the unadopted 0.1.1-address deploy) MUST be owned by
/// Base's token-owner Safe.
function testProdDeployBaseV4() external {
vm.createSelectFork(LibRainDeploy.BASE);
checkAllV4OnChain();
LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid);
}

/// Only the audited 0.1.1 production set is shipped to Ethereum mainnet (the
/// orchestrator and 0.1.3 rebuilds are Base-only), so the Ethereum fork is
/// checked against the 0.1.1 set alone.
/// checked against the 0.1.1 set alone. On Ethereum the 0.1.1 beacons ARE
/// the in-use production beacons, and the beacon-ownership migration
/// (`20260716-migrate-beacon-owners-ethereum`) has transferred them to
/// Ethereum's token-owner Safe — asserted via the per-chain in-use pin.
function testProdDeployEthereumV4() external {
vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM);
checkProd_0_1_1OnChain();
LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid);
}
}
79 changes: 78 additions & 1 deletion test/src/lib/LibBeaconInvariants.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ import {
LibBeaconInvariants,
IOwnable,
BeaconCodehashMismatch,
BeaconNotDeployed,
BeaconOwnerMismatch,
BeaconImplementationMismatch
BeaconImplementationMismatch,
UnsupportedChainForProdBeacons
} from "../../../src/lib/LibBeaconInvariants.sol";
import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol";
import {LibProdBeaconsBase} from "../../../src/lib/LibProdBeaconsBase.sol";
import {LibProdBeaconsEthereum} from "../../../src/lib/LibProdBeaconsEthereum.sol";
import {LibProdDeployV1} from "../../../src/lib/LibProdDeployV1.sol";
import {LibStoxDeployNetworks} from "../../../src/lib/LibStoxDeployNetworks.sol";
import {LibBeaconInvariantsHarness} from "./LibBeaconInvariantsHarness.sol";
import {LibRainDeploy} from "rain-deploy-0.1.4/src/lib/LibRainDeploy.sol";
import {IBeacon} from "@openzeppelin-contracts-5.6.1/proxy/beacon/IBeacon.sol";
Expand Down Expand Up @@ -109,4 +114,76 @@ contract LibBeaconInvariantsTest is Test {
beacon, LibBeaconInvariants.PROD_BEACON_OWNER, LibProdDeployV1.STOX_RECEIPT_VAULT_IMPLEMENTATION
);
}

/// @notice Base's IN-USE beacons are the V1-generation addresses. The
/// later 0.1.1-address beacons exist on Base but production never adopted
/// them, so returning those would assert ownership of a deploy artifact
/// nothing runs on while leaving the live beacons unpinned.
function testProdBeaconsForChainIdBaseIsTheV1Generation() external {
selectBaseFork();
address[3] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.BASE_CHAIN_ID);
assertEq(beacons[0], LibProdDeployV1.STOX_RECEIPT_BEACON_V1, "receipt beacon");
assertEq(beacons[1], LibProdDeployV1.STOX_RECEIPT_VAULT_BEACON_V1, "receipt vault beacon");
assertEq(beacons[2], LibProdDeployV1.STOX_WRAPPED_TOKEN_VAULT_BEACON_V1, "wrapped vault beacon");
}

/// @notice Ethereum bootstrapped at 0.1.1, so its in-use beacons are that
/// generation — the two read live from the 0.1.1 beacon-set deployer plus
/// the wrapped beacon's own pin. Pinned against the source lib so the
/// chain-id dispatch cannot silently answer with Base's set.
function testProdBeaconsForChainIdEthereumIsThe011Set() external {
vm.createSelectFork(LibStoxDeployNetworks.ETHEREUM);
harness = new LibBeaconInvariantsHarness();
address[3] memory beacons = harness.callProdBeaconsForChainId(LibSafeInvariants.ETHEREUM_CHAIN_ID);
address[3] memory expected = LibProdBeaconsEthereum.beacons();
assertEq(beacons[0], expected[0], "receipt beacon");
assertEq(beacons[1], expected[1], "receipt vault beacon");
assertEq(beacons[2], expected[2], "wrapped vault beacon");
assertTrue(beacons[0] != LibProdDeployV1.STOX_RECEIPT_BEACON_V1, "answered with Base's set");
}

/// @notice A chain with no pinned in-use set reverts rather than falling
/// back to another chain's beacons. A fallback would assert ownership of
/// contracts that do not exist on the active chain, and `code.length == 0`
/// would report that as a missing beacon rather than as an unsupported
/// chain.
function testProdBeaconsForChainIdRevertsForUnpinnedChain() external {
selectBaseFork();
uint256 arbitrum = 42161;
vm.expectRevert(abi.encodeWithSelector(UnsupportedChainForProdBeacons.selector, arbitrum));
harness.callProdBeaconsForChainId(arbitrum);
}

/// @notice `assertProdBeaconsOwnedByChainSafe` trips `BeaconOwnerMismatch`
/// when an in-use beacon is held by anything other than the active chain's
/// token-owner Safe. Whoever owns an in-use beacon can repoint every
/// production vault proxy on the chain, so the rogue owner here stands in
/// for the whole class of compromise this assert exists to catch.
function testInvertedProdBeaconOwnerMismatch() external {
selectBaseFork();
address beacon = LibProdBeaconsBase.beacons()[1];
address rogueOwner = address(0xBADC0DE);
vm.mockCall(beacon, abi.encodeWithSelector(IOwnable.owner.selector), abi.encode(rogueOwner));
vm.expectRevert(
abi.encodeWithSelector(
BeaconOwnerMismatch.selector,
beacon,
LibSafeInvariants.safeForChainId(LibSafeInvariants.BASE_CHAIN_ID),
rogueOwner
)
);
harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID);
}

/// @notice An in-use beacon with no code trips `BeaconNotDeployed` rather
/// than reaching `owner()`. A staticcall to a codeless address succeeds
/// returning nothing, so without this guard the failure would surface as a
/// decode revert that names neither the beacon nor the reason.
function testInvertedProdBeaconNotDeployed() external {
selectBaseFork();
address beacon = LibProdBeaconsBase.beacons()[0];
vm.etch(beacon, "");
vm.expectRevert(abi.encodeWithSelector(BeaconNotDeployed.selector, beacon));
harness.callAssertProdBeaconsOwnedByChainSafe(LibSafeInvariants.BASE_CHAIN_ID);
}
}
8 changes: 8 additions & 0 deletions test/src/lib/LibBeaconInvariantsHarness.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,12 @@ contract LibBeaconInvariantsHarness {
function callAssertBeaconInvariants(address beacon, address expectedOwner, address expectedImpl) external view {
LibBeaconInvariants.assertBeaconInvariants(beacon, expectedOwner, expectedImpl);
}

function callProdBeaconsForChainId(uint256 chainId) external view returns (address[3] memory) {
return LibBeaconInvariants.prodBeaconsForChainId(chainId);
}

function callAssertProdBeaconsOwnedByChainSafe(uint256 chainId) external view {
LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(chainId);
}
}
Loading
Loading