From 9a0cc8d6b7cf8814692d0c7444ce26ccb8fbcab3 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:07:52 +0000 Subject: [PATCH 01/29] feat(registry): address registry interface, reader lib and cross-network gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic deploys and configured addresses are in tension: the CREATE2 address is a function of the creation code, so any address baked into a contract is part of its identity. Configured addresses therefore get hardcoded, one copy per repo, and can never be changed without moving deployments. `IAddressRegistryV1` is the read-at-run-time alternative. An immutable root authority binds an opaque `bytes32` name to an address, once; nothing, root included, can change one after; and reading an unbound name reverts rather than answering with the zero address. `LibAddressRegistry.resolve` reads it at its deterministic Zoltu address, verifying the registry's code hash first, the same way `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH`. It resolves a name and stops there. `LibRainDeploy.checkRegisteredAddressesOnNetworks` is the deploy-time gate that belongs beside the multi-network broadcast rather than in every consumer's deploy script: every name must resolve to the address the deployment expects, on every target network. Write-once is what makes that pre-flight as strong as an inline check — an answer that exists cannot change, and one that does not exist reverts. The implementation, `AddressRegistry`, lives in rain.factory.deploy; `ADDRESS_REGISTRY` and `ADDRESS_REGISTRY_CODEHASH` pin it, derived from its creation code. --- CLAUDE.md | 84 +++++++--- README.md | 28 ++++ src/interface/IAddressRegistryV1.sol | 82 ++++++++++ src/lib/LibAddressRegistry.sol | 61 ++++++++ src/lib/LibRainDeploy.sol | 71 +++++++++ test/lib/AddressRegistryPins.sol | 29 ++++ test/src/lib/LibAddressRegistry.t.sol | 102 ++++++++++++ test/src/lib/LibRainDeploy.t.sol | 214 ++++++++++++++++++++++++++ 8 files changed, 651 insertions(+), 20 deletions(-) create mode 100644 src/interface/IAddressRegistryV1.sol create mode 100644 src/lib/LibAddressRegistry.sol create mode 100644 test/lib/AddressRegistryPins.sol create mode 100644 test/src/lib/LibAddressRegistry.t.sol diff --git a/CLAUDE.md b/CLAUDE.md index 9fbab8a..b33f19d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,15 +3,21 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with +code in this repository. ## Project Overview -rain.deploy is a Solidity library for deploying Rain Protocol contracts via the Zoltu deterministic deployment proxy to multiple EVM networks. It ensures identical contract addresses across all supported chains (Arbitrum, Base, Flare, Polygon) because the Zoltu proxy is deployed at the same address on every chain and uses CREATE with a predictable nonce. +rain.deploy is a Solidity library for deploying Rain Protocol contracts via the +Zoltu deterministic deployment proxy to multiple EVM networks. It ensures +identical contract addresses across all supported chains (Arbitrum, Base, Flare, +Polygon) because the Zoltu proxy is deployed at the same address on every chain +and uses CREATE with a predictable nonce. ## Build & Development -This project uses **Foundry** (forge) for Solidity development and **Nix** for environment management. +This project uses **Foundry** (forge) for Solidity development and **Nix** for +environment management. ```bash # Enter the nix dev shell (provides forge and all tooling) @@ -33,40 +39,78 @@ nix develop -c rainix-sol-static nix develop -c rainix-sol-legal ``` -CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, `rainix-sol-static`. +CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, +`rainix-sol-static`. ## RPC Configuration Fork tests require RPC endpoints defined in `.env` (gitignored): + ```bash ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc BASE_RPC_URL=https://mainnet.base.org FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc POLYGON_RPC_URL=https://polygon-rpc.com ``` + These are referenced in `foundry.toml` under `[rpc_endpoints]`. ## Architecture -The entire library is a single file: `src/lib/LibRainDeploy.sol`. - -**LibRainDeploy** provides: -- `etchZoltuFactory(Vm)` — etches the Zoltu factory bytecode at the factory address (for networks where it isn't deployed) -- `deployZoltu(bytes creationCode)` — deploys creation code via the Zoltu factory (`0x7A0D94F55792C434d74a40883C6ed8545E406D12`) using low-level `call`, returns the deployed address -- `supportedNetworks()` — returns the list of Rain-supported network names (used as foundry RPC config aliases) -- `checkDependencies(...)` — forks each network, verifies dependencies and Zoltu factory exist with expected codehashes -- `deployToNetworks(...)` — re-verifies dependencies, deploys via Zoltu, verifies address and code hash -- `deployAndBroadcast(...)` — the main entry point: derives deployer from private key, calls `checkDependencies` then `deployToNetworks` - -The library is designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, expected addresses, expected code hashes, and dependency lists. +**`src/lib/LibRainDeploy.sol`** — the deploy library: + +- `etchZoltuFactory(Vm)` — etches the Zoltu factory bytecode at the factory + address (for networks where it isn't deployed) +- `zoltuAddress(bytes creationCode)` — derives the address the factory deploys + creation code to, without deploying +- `deployZoltu(bytes creationCode)` — deploys creation code via the Zoltu + factory (`0x7A0D94F55792C434d74a40883C6ed8545E406D12`) using low-level `call`, + returns the deployed address +- `supportedNetworks()` — returns the list of Rain-supported network names (used + as foundry RPC config aliases) +- `isStartBlock(...)` / `findDeployBlock(...)` — binary search a fork's history + for the block a contract first appears at +- `checkRegisteredAddresses(...)` — asserts names resolve to their expected + addresses in the address registry, on the currently selected fork +- `checkRegisteredAddressesOnNetworks(...)` — runs that check on every network, + so a deployment's resolved addresses are gated across the whole target set + here rather than in each consumer's deploy script +- `deployToNetworks(...)` — forks each network, verifies the factory and + dependencies, deploys via Zoltu, verifies address and code hash +- `deployAndBroadcast(...)` — the main entry point: derives the deployer from a + private key, then `deployToNetworks` + +**`src/interface/IAddressRegistryV1.sol`** — the address registry interface: an +immutable root binds a `bytes32` name to an address once and forever +(`register`), anyone reads a bound name (`get`), and reading an unbound name +reverts. The implementation is `AddressRegistry` in +[rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy). + +**`src/lib/LibAddressRegistry.sol`** — reads that registry at its deterministic +address, verifying its code hash first, exactly as `LibRainDeploy` verifies +`ZOLTU_FACTORY_CODEHASH`. It resolves a name to an address and nothing more: +what a consumer resolves a name for, and when, is the consumer's business. + +The libraries are designed to be called from Foundry scripts (`forge script`) in +consuming repos, not directly. Consuming repos provide their own creation code, +expected addresses, expected code hashes, and dependency lists. ## Key Design Patterns -- **Deterministic addresses**: Zoltu proxy ensures same address on every chain. Deployments fail if the resulting address doesn't match `expectedAddress`. -- **Code hash verification**: Post-deploy bytecode integrity is verified against `expectedCodeHash`. -- **Dependency checking**: Before deploying to any network, all dependencies (contract addresses) are verified to have code on-chain. -- **Idempotent deploys**: If code already exists at the expected address, deployment is skipped for that network. +- **Deterministic addresses**: Zoltu proxy ensures same address on every chain. + Deployments fail if the resulting address doesn't match `expectedAddress`. +- **Code hash verification**: Post-deploy bytecode integrity is verified against + `expectedCodeHash`. The address registry is verified the same way before it is + read. +- **Dependency checking**: Before deploying to any network, all dependencies + (contract addresses) are verified to have code on-chain. +- **Idempotent deploys**: If code already exists at the expected address, + deployment is skipped for that network. +- **Write-once bindings**: registry bindings can never move, which is what makes + checking them before a deploy meaningful rather than a race, and what lets the + cross-network check be a pre-flight over every network. ## License -DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX headers. REUSE compliance is enforced in CI. +DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX +headers. REUSE compliance is enforced in CI. diff --git a/README.md b/README.md index 9c2eb72..38b3da0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ It answers: - Have I deployed successfully to all expected networks? - How do I track deployments over time and share addresses with other people? - How do I ensure deployed code is bytecode-equivalent to local compilations? +- How does a deployment get a configured address — an owner, say — without + baking one into its creation code, where changing it would move every future + deployment? Approach: @@ -25,6 +28,31 @@ Approach: and against the chain after: silent failures fail loudly. - Bytecode integrity checks (e.g. via the Rain Extrospection lib) supported post-deploy. +- A write-once address registry, read at run time rather than compiled in, and + gated across every target network before a deploy. + +## Address registry + +`IAddressRegistryV1` binds an opaque `bytes32` name to an address. An immutable +root authority binds a name that is unbound; nothing, root included, can change +one after; and reading an unbound name reverts rather than answering with the +zero address. There is no rotation, no removal and no admin surface, because a +binding that can move is not worth checking before a deploy. + +`LibAddressRegistry.resolve` reads it, verifying the registry's code hash first, +the same way `LibRainDeploy` verifies the Zoltu factory's. It resolves a name to +an address and stops there — what a consumer does with the address, and when, is +the consumer's business. + +`LibRainDeploy.checkRegisteredAddressesOnNetworks` is the deploy-time gate: +every name must resolve to the address the deployment expects, on every target +network, before anything is broadcast. Because bindings are write-once, that +pre-flight is exactly as strong as checking inline — an answer that exists +cannot change, and one that does not exist reverts. + +The implementation, `AddressRegistry`, lives in +[rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy); +`LibAddressRegistry` pins its deterministic address and code hash. ## Install diff --git a/src/interface/IAddressRegistryV1.sol b/src/interface/IAddressRegistryV1.sol new file mode 100644 index 0000000..d10f1b1 --- /dev/null +++ b/src/interface/IAddressRegistryV1.sol @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title IAddressRegistryV1 +/// @notice A registry of `bytes32` names to addresses with exactly two +/// operations: an immutable root authority binds a name that is unbound, and +/// anyone reads a name that is bound. There is no rotation, no removal, no +/// upgrade and no admin surface, and an implementation MUST NOT add any: the +/// value of the registry is that a binding, once made, is a constant. +/// +/// Names are opaque 32-byte values. This interface says nothing about how a +/// name is derived — hashed from a string, a raw ASCII literal, a counter — and +/// an implementation MUST NOT constrain it. Two callers agreeing on a name is +/// entirely their business. +/// +/// The write-once property is what makes a deploy-time check of a binding worth +/// anything. Against a mutable value the check would be a race, because the +/// value could move between the check and the read that consumes it. Here, once +/// `get` returns for a name, it returns the same address forever. +/// +/// Compromising root therefore cannot change any existing binding. It can reach +/// a network nobody has deployed to yet and bind the intended names against +/// itself, burning them there, which forces a different name on that network +/// and moves addresses on that network only. That is a loud, per-network loss +/// of determinism, never a silent or retroactive change. +interface IAddressRegistryV1 { + /// Thrown when an account that is not the root authority calls `register`. + /// @param sender The `msg.sender` that was not root. + error NotRoot(address sender); + + /// Thrown when `register` is called for a name that is already bound. + /// Bindings are write-once, so this is thrown even for root, and even when + /// the account being registered is the account already bound. + /// @param name The name that is already bound. + /// @param account The address `name` is bound to. + error NameAlreadyRegistered(bytes32 name, address account); + + /// Thrown when `register` is called with the zero address. The zero address + /// is how an unbound name reads, so binding it would produce a name that is + /// both bound and unreadable, and that `register` would accept a second + /// time. + /// @param name The name that was being bound to the zero address. + error ZeroAccount(bytes32 name); + + /// Thrown by `get` when a name has never been bound, so that a caller + /// cannot silently proceed on the zero address by forgetting to check. + /// @param name The name that is not bound. + error NameNotRegistered(bytes32 name); + + /// Emitted when `name` is bound to `account`. Bindings are write-once, so + /// exactly one `Register` is ever emitted per name, and the log is the + /// complete enumeration of the registry — there is no other way to discover + /// a binding without already knowing the name. Both parameters are indexed + /// for that reason: the log has to answer "what is this name bound to" and + /// "what did root bind to this address" without a full scan. + /// @param name The name that was bound. + /// @param account The address `name` was bound to. + event Register(bytes32 indexed name, address indexed account); + + /// Binds `name` to `account`, permanently. + /// + /// The implementation MUST revert `NotRoot` unless the caller is the root + /// authority, MUST revert `ZeroAccount` if `account` is the zero address, + /// and MUST revert `NameAlreadyRegistered` if `name` is already bound — + /// including when the caller is root and including when `account` is the + /// address already bound. On success it MUST emit `Register`. + /// @param name The name to bind. + /// @param account The address to bind it to. + function register(bytes32 name, address account) external; + + /// The address `name` is bound to. + /// + /// The implementation MUST revert `NameNotRegistered` when `name` is + /// unbound, rather than returning the zero address, so that no caller has + /// to remember to check. It MUST NOT expose any other reader that returns + /// the zero address for an unbound name, as that reintroduces exactly the + /// mistake this reverting read exists to prevent. + /// @param name The name to read. + /// @return account The address bound to `name`. Never the zero address. + function get(bytes32 name) external view returns (address account); +} diff --git a/src/lib/LibAddressRegistry.sol b/src/lib/LibAddressRegistry.sol new file mode 100644 index 0000000..b18f7c6 --- /dev/null +++ b/src/lib/LibAddressRegistry.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; + +/// @title LibAddressRegistry +/// @notice Reads the `IAddressRegistryV1` deployed at a single deterministic +/// address on every network, verifying the registry's code hash first, exactly +/// as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH` before using the Zoltu +/// factory. An address alone says nothing on a chain the caller has not +/// audited; the address plus the code hash says the caller is talking to the +/// registry it compiled against. +/// +/// That is the whole library. It resolves a name to an address. What a consumer +/// resolves a name for, and when — an owner set in a constructor or an +/// initializer, under `Ownable` or RBAC or nothing at all — is entirely the +/// consumer's business and none of this library's. +library LibAddressRegistry { + /// Thrown when the code at the registry address is not the registry this + /// library was compiled against. An address with no code hits this too: an + /// empty account's code hash is zero (or the hash of empty code), never the + /// expected value. + /// @param expectedCodeHash The code hash of the pinned registry. + /// @param actualCodeHash The code hash actually found at the address. + error UnexpectedAddressRegistryCodeHash(bytes32 expectedCodeHash, bytes32 actualCodeHash); + + /// The deterministic Zoltu deploy address of `AddressRegistry`, the same on + /// every network. + /// + /// Derived from that contract's creation code, not observed from a chain: + /// the Zoltu factory is `CREATE2` over its calldata with a zero salt, so the + /// address is a pure function of the creation code + /// (`LibRainDeploy.zoltuAddress`). The root authority is a constant in that + /// creation code, so changing the root moves this address, and both this and + /// `ADDRESS_REGISTRY_CODEHASH` MUST be re-derived whenever it changes. + address constant ADDRESS_REGISTRY = 0x619e47868cE4a9AEbBD6444c9385f1558c79ED52; + + /// The code hash of `AddressRegistry` once deployed, i.e. `keccak256` over + /// the runtime code its creation code leaves behind. Derived from the same + /// compilation as `ADDRESS_REGISTRY`, and moves with it. + bytes32 constant ADDRESS_REGISTRY_CODEHASH = 0x01e8bf67abc9d4b4abe2d39c66c07c1b02e39bdf559c694f94f5853bad6394d8; + + /// The address a name is bound to in the registry. + /// + /// Verifies the registry's code hash before reading, so a chain where the + /// registry is absent, or where something else occupies its address, is a + /// loud revert rather than a call into unknown code. The registry itself + /// reverts on an unbound name, so a returned address is always a real + /// binding and is never the zero address. + /// @param name The name to resolve. Opaque; the registry constrains nothing + /// about how it was derived. + /// @return The address bound to `name`. + function resolve(bytes32 name) internal view returns (address) { + bytes32 actualCodeHash = ADDRESS_REGISTRY.codehash; + if (actualCodeHash != ADDRESS_REGISTRY_CODEHASH) { + revert UnexpectedAddressRegistryCodeHash(ADDRESS_REGISTRY_CODEHASH, actualCodeHash); + } + return IAddressRegistryV1(ADDRESS_REGISTRY).get(name); + } +} diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index 39c5175..a34ca6a 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.1/src/Vm.sol"; import {console2} from "forge-std-1.16.1/src/console2.sol"; +import {LibAddressRegistry} from "./LibAddressRegistry.sol"; /// @title LibRainDeploy /// Library for deploying contracts via the Zoltu factory across all the networks @@ -40,6 +41,14 @@ library LibRainDeploy { /// the deploy may have happened before the search range. error DeployedBeforeStartBlock(address target, uint256 startBlock); + /// Thrown when a registry name resolves to something other than the address + /// the deployment expects on a network. + error UnexpectedRegisteredAddress(string network, bytes32 name, address expected, address actual); + + /// Thrown when the names and expected addresses of a registry check do not + /// pair up. + error RegisteredAddressesLengthMismatch(uint256 namesLength, uint256 expectedAddressesLength); + /// Zoltu factory is the same on every network. address constant ZOLTU_FACTORY = 0x7A0D94F55792C434d74a40883C6ed8545E406D12; @@ -198,6 +207,68 @@ library LibRainDeploy { return networks; } + /// Asserts that each name resolves, in the address registry, to the address + /// the deployment expects, on whichever network is currently selected. + /// Verifying the registry's code hash is `LibAddressRegistry.resolve`'s job, + /// and an unbound name reverts there rather than resolving to nothing, so + /// every way this can be wrong is a revert. + /// @param network The network name, for the error only. + /// @param names The names to resolve. + /// @param expectedAddresses The address each name MUST resolve to, + /// positionally paired with `names`. + function checkRegisteredAddresses(string memory network, bytes32[] memory names, address[] memory expectedAddresses) + internal + view + { + if (names.length != expectedAddresses.length) { + revert RegisteredAddressesLengthMismatch(names.length, expectedAddresses.length); + } + for (uint256 i = 0; i < names.length; i++) { + address actual = LibAddressRegistry.resolve(names[i]); + if (actual != expectedAddresses[i]) { + revert UnexpectedRegisteredAddress(network, names[i], expectedAddresses[i], actual); + } + } + } + + /// Runs `checkRegisteredAddresses` over every network, so a deployment + /// asserts its resolved addresses agree across the whole target set here, + /// rather than every consumer's deploy script forking the networks itself. + /// + /// Registry bindings are write-once, so this is a pre-flight rather than a + /// race: a name that resolves here cannot resolve differently later, and a + /// name that is unbound here reverts here. Checking every network before + /// deploying to any of them means a network that disagrees stops the + /// deployment instead of leaving it half-applied. + /// @param vm The Vm instance to use for forking. + /// @param networks The list of network names to check. + /// @param names The names to resolve on each network. + /// @param expectedAddresses The address each name MUST resolve to, + /// positionally paired with `names`. + function checkRegisteredAddressesOnNetworks( + Vm vm, + string[] memory networks, + bytes32[] memory names, + address[] memory expectedAddresses + ) internal { + if (networks.length == 0) { + revert NoNetworks(); + } + // Checked before any fork so a mispaired call fails immediately rather + // than after an RPC round trip. + if (names.length != expectedAddresses.length) { + revert RegisteredAddressesLengthMismatch(names.length, expectedAddresses.length); + } + for (uint256 i = 0; i < networks.length; i++) { + // createSelectFork returns a fork id that is not needed here; bind + // and reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + console2.log("Checking registered addresses on network:", networks[i]); + checkRegisteredAddresses(networks[i], names, expectedAddresses); + } + } + /// Deploys the given creation code to each network via the Zoltu factory. /// `expectedAddress` MUST be the address the Zoltu factory derives for /// `creationCode`, which is checked before any network is forked, so an diff --git a/test/lib/AddressRegistryPins.sol b/test/lib/AddressRegistryPins.sol new file mode 100644 index 0000000..dccf716 --- /dev/null +++ b/test/lib/AddressRegistryPins.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @dev The creation code of `AddressRegistry`, the `IAddressRegistryV1` +/// implementation in +/// [rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy), +/// as compiled by that repo (`solc 0.8.25`, optimizer on at 100,000 runs, evm +/// version `cancun`, no metadata): +/// +/// ```sh +/// forge inspect src/concrete/AddressRegistry.sol:AddressRegistry bytecode +/// ``` +/// +/// This library cannot depend on that repo — it depends on this one — so the +/// creation code is carried here instead, and it is what makes the pins in +/// `LibAddressRegistry` checkable rather than asserted: deploying this through +/// the Zoltu factory MUST land at `ADDRESS_REGISTRY` with +/// `ADDRESS_REGISTRY_CODEHASH`. If the registry's source changes — and the root +/// authority baked into it is part of that source — this blob and both pins +/// change together, and the test that deploys it says so. +bytes constant ADDRESS_REGISTRY_CREATION_CODE = + hex"6080604052348015600e575f80fd5b506102e68061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b610046366004610289565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b6100876100823660046102a0565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16801561020b576040517f7887e8c00000000000000000000000000000000000000000000000000000000081526004810184905273ffffffffffffffffffffffffffffffffffffffff821660248201526044016100e3565b5f8381526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff86169081179091559051909185917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a3505050565b5f60208284031215610299575f80fd5b5035919050565b5f80604083850312156102b1575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff811681146102db575f80fd5b80915050925092905056"; + +/// @dev The root authority baked into `ADDRESS_REGISTRY_CREATION_CODE`, needed +/// to bind a name in a test. Currently the placeholder `rain.factory.deploy` +/// carries until a human supplies the real root; when that happens the creation +/// code above and both `LibAddressRegistry` pins change with it. +address constant ADDRESS_REGISTRY_ROOT = address(0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD); diff --git a/test/src/lib/LibAddressRegistry.t.sol b/test/src/lib/LibAddressRegistry.t.sol new file mode 100644 index 0000000..160e6e9 --- /dev/null +++ b/test/src/lib/LibAddressRegistry.t.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 {Test} from "forge-std-1.16.1/src/Test.sol"; +import {LibAddressRegistry} from "../../../src/lib/LibAddressRegistry.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {ADDRESS_REGISTRY_CREATION_CODE, ADDRESS_REGISTRY_ROOT} from "../../lib/AddressRegistryPins.sol"; + +/// @title LibAddressRegistryTest +/// Tests for `LibAddressRegistry`. The registry is not mocked: the real +/// `AddressRegistry` creation code is deployed through the Zoltu factory, which +/// is what puts it at the pinned address with the pinned code hash, so every +/// test here runs against the same bytecode a network would. +/// +/// External wrappers are used for the library function so `vm.expectRevert` +/// lands at the correct call depth. +contract LibAddressRegistryTest is Test { + /// Deploys the pinned `AddressRegistry` creation code through the Zoltu + /// factory, which lands it at `LibAddressRegistry.ADDRESS_REGISTRY`. + /// @return The deployed registry. + function deployRegistry() internal returns (IAddressRegistryV1) { + LibRainDeploy.etchZoltuFactory(vm); + return IAddressRegistryV1(LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE)); + } + + /// External wrapper for `resolve` so that `vm.expectRevert` works at the + /// correct call depth. + /// @param name The name to resolve. + /// @return The address bound to `name`. + function externalResolve(bytes32 name) external view returns (address) { + return LibAddressRegistry.resolve(name); + } + + /// The pins are derived from the registry's creation code, not asserted: + /// deploying that creation code through the Zoltu factory MUST land at + /// `ADDRESS_REGISTRY` with `ADDRESS_REGISTRY_CODEHASH`. The address is also + /// derivable without deploying at all, and both derivations MUST agree. + function testAddressRegistryPinsAreDerivable() external { + assertEq(LibRainDeploy.zoltuAddress(ADDRESS_REGISTRY_CREATION_CODE), LibAddressRegistry.ADDRESS_REGISTRY); + + address deployed = address(deployRegistry()); + assertEq(deployed, LibAddressRegistry.ADDRESS_REGISTRY); + assertEq(deployed.codehash, LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + assertEq(keccak256(deployed.code), LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + } + + /// A bound name resolves to the address it is bound to. + function testResolveRegistered(bytes32 name, address account) external { + vm.assume(account != address(0)); + IAddressRegistryV1 registry = deployRegistry(); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + + assertEq(LibAddressRegistry.resolve(name), account); + } + + /// An unbound name reverts. The registry, not this library, is what refuses + /// to answer with the zero address, so the revert arrives unmodified. + function testResolveUnregistered(bytes32 name) external { + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + this.externalResolve(name); + } + + /// A chain with no registry deployed reverts on the code hash rather than + /// calling into an empty account, which would otherwise succeed silently + /// and return nothing. + function testResolveNoRegistry(bytes32 name) external { + assertEq(LibAddressRegistry.ADDRESS_REGISTRY.code.length, 0); + + vm.expectRevert( + abi.encodeWithSelector( + LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, + LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH, + bytes32(0) + ) + ); + this.externalResolve(name); + } + + /// A chain where something other than the pinned registry occupies the + /// address reverts on the code hash, so a name is never resolved by code + /// the caller did not compile against. + function testResolveWrongCode(bytes32 name, bytes memory code) external { + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + vm.etch(LibAddressRegistry.ADDRESS_REGISTRY, code); + + vm.expectRevert( + abi.encodeWithSelector( + LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, + LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH, + keccak256(code) + ) + ); + this.externalResolve(name); + } +} diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index 4991305..4e9c7d8 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -4,9 +4,12 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {LibAddressRegistry} from "../../../src/lib/LibAddressRegistry.sol"; +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import {MockReverter} from "../../concrete/MockReverter.sol"; +import {ADDRESS_REGISTRY_CREATION_CODE, ADDRESS_REGISTRY_ROOT} from "../../lib/AddressRegistryPins.sol"; /// @title LibRainDeployTest /// Tests for `LibRainDeploy`. External wrappers are used for library functions @@ -627,4 +630,215 @@ contract LibRainDeployTest is Test { dependencies ); } + + /// Deploys the real `AddressRegistry` creation code through the Zoltu + /// factory, which lands it at `LibAddressRegistry.ADDRESS_REGISTRY`, and + /// binds `name` to `account` as root. + /// @param name The name to bind. + /// @param account The address to bind it to. + function deployRegistryWithBinding(bytes32 name, address account) internal { + LibRainDeploy.etchZoltuFactory(vm); + IAddressRegistryV1 registry = IAddressRegistryV1(LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE)); + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + } + + /// External wrapper for `checkRegisteredAddresses` so that + /// `vm.expectRevert` works at the correct call depth. + /// @param network The network name, for the error only. + /// @param names The names to resolve. + /// @param expectedAddresses The address each name MUST resolve to. + function externalCheckRegisteredAddresses( + string memory network, + bytes32[] memory names, + address[] memory expectedAddresses + ) external view { + LibRainDeploy.checkRegisteredAddresses(network, names, expectedAddresses); + } + + /// External wrapper for `checkRegisteredAddressesOnNetworks` so that + /// `vm.expectRevert` works at the correct call depth. + /// @param networks The list of network names to check. + /// @param names The names to resolve on each network. + /// @param expectedAddresses The address each name MUST resolve to. + function externalCheckRegisteredAddressesOnNetworks( + string[] memory networks, + bytes32[] memory names, + address[] memory expectedAddresses + ) external { + LibRainDeploy.checkRegisteredAddressesOnNetworks(vm, networks, names, expectedAddresses); + } + + /// `checkRegisteredAddresses` MUST pass when every name resolves to the + /// address paired with it. + function testCheckRegisteredAddressesMatch(bytes32 name, address account) external { + vm.assume(account != address(0)); + deployRegistryWithBinding(name, account); + + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = account; + + LibRainDeploy.checkRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + } + + /// `checkRegisteredAddresses` MUST revert with `UnexpectedRegisteredAddress` + /// when a name resolves to an address other than the expected one, naming + /// the network so the failure identifies where it disagrees. + function testCheckRegisteredAddressesMismatchReverts(bytes32 name, address account, address expected) external { + vm.assume(account != address(0)); + vm.assume(expected != account); + deployRegistryWithBinding(name, account); + + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = expected; + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, name, expected, account + ) + ); + this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + } + + /// `checkRegisteredAddresses` MUST check every name, not only the first, so + /// a later name that disagrees still stops the deployment. + function testCheckRegisteredAddressesChecksEveryName( + bytes32 nameA, + bytes32 nameB, + address account, + address expected + ) external { + vm.assume(nameA != nameB); + vm.assume(account != address(0)); + vm.assume(expected != account); + deployRegistryWithBinding(nameA, account); + vm.prank(ADDRESS_REGISTRY_ROOT); + IAddressRegistryV1(LibAddressRegistry.ADDRESS_REGISTRY).register(nameB, account); + + bytes32[] memory names = new bytes32[](2); + names[0] = nameA; + names[1] = nameB; + address[] memory expectedAddresses = new address[](2); + expectedAddresses[0] = account; + expectedAddresses[1] = expected; + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, nameB, expected, account + ) + ); + this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + } + + /// `checkRegisteredAddresses` MUST propagate the registry's own revert for + /// an unbound name, so a network where a name was never bound fails as + /// loudly as one where it disagrees. + function testCheckRegisteredAddressesUnregisteredReverts(bytes32 name, address expected) external { + LibRainDeploy.etchZoltuFactory(vm); + LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE); + + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = expected; + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + } + + /// `checkRegisteredAddresses` MUST revert when the names and expected + /// addresses do not pair up, rather than checking the shorter of the two. + function testCheckRegisteredAddressesLengthMismatchReverts(uint8 namesLength, uint8 expectedLength) external { + vm.assume(namesLength != expectedLength); + + bytes32[] memory names = new bytes32[](namesLength); + address[] memory expectedAddresses = new address[](expectedLength); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.RegisteredAddressesLengthMismatch.selector, uint256(namesLength), uint256(expectedLength) + ) + ); + this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + } + + /// `checkRegisteredAddressesOnNetworks` MUST revert with `NoNetworks` when + /// given none, so an empty target set can never be mistaken for every name + /// checking out. + function testCheckRegisteredAddressesOnNetworksNoNetworksReverts(bytes32 name, address expected) external { + string[] memory networks = new string[](0); + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = expected; + + vm.expectRevert(abi.encodeWithSelector(LibRainDeploy.NoNetworks.selector)); + this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + } + + /// `checkRegisteredAddressesOnNetworks` MUST check the names and expected + /// addresses pair up before it forks anything, so a mispaired call is + /// reported without any network being reachable at all. + function testCheckRegisteredAddressesOnNetworksLengthMismatchRevertsBeforeForking() external { + string[] memory networks = new string[](1); + // Not a configured RPC alias, so forking it is itself an error. + networks[0] = "unconfigured_network"; + bytes32[] memory names = new bytes32[](2); + address[] memory expectedAddresses = new address[](1); + + vm.expectRevert( + abi.encodeWithSelector(LibRainDeploy.RegisteredAddressesLengthMismatch.selector, uint256(2), uint256(1)) + ); + this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + } + + /// `checkRegisteredAddressesOnNetworks` MUST pass over every supported + /// network when the name resolves to the expected address on each. The + /// registry is made persistent so the same binding is present on every + /// fork, which is the state the check exists to confirm. Fixed inputs + /// rather than fuzzed: what varies here is the network, and every run forks + /// all five. + function testCheckRegisteredAddressesOnNetworksAllNetworks() external { + bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksAllNetworks"); + address account = address(0xf00); + deployRegistryWithBinding(name, account); + vm.makePersistent(LibAddressRegistry.ADDRESS_REGISTRY); + + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = account; + + LibRainDeploy.checkRegisteredAddressesOnNetworks( + vm, LibRainDeploy.supportedNetworks(), names, expectedAddresses + ); + } + + /// `checkRegisteredAddressesOnNetworks` MUST fail on the network that + /// disagrees, and MUST name it. + function testCheckRegisteredAddressesOnNetworksMismatchReverts() external { + bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksMismatchReverts"); + address account = address(0xf00); + address expected = address(0xba4); + deployRegistryWithBinding(name, account); + vm.makePersistent(LibAddressRegistry.ADDRESS_REGISTRY); + + string[] memory networks = new string[](1); + networks[0] = LibRainDeploy.BASE; + bytes32[] memory names = new bytes32[](1); + names[0] = name; + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = expected; + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, name, expected, account + ) + ); + this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + } } From 0958f42136d4700fd4b8814b3026143b7d1dffad Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:15:23 +0000 Subject: [PATCH 02/29] test(registry): fork two networks in the cross-network gate test, not five The multi-network test forked every entry of `supportedNetworks()`, and CI's `base_sepolia` endpoint times out on its free plan ("Request timeout on the free plan"), so the test failed on infrastructure rather than on the code. What the test is for is that the loop visits every network it is given, which two prove as well as five. The roster itself is `testSupportedNetworks`'s job. Arbitrum and Base are the networks the rest of the suite already forks, so the test no longer depends on endpoints nothing else here touches. --- test/src/lib/LibRainDeploy.t.sol | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index 4e9c7d8..3fa79fa 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -796,26 +796,33 @@ contract LibRainDeployTest is Test { this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); } - /// `checkRegisteredAddressesOnNetworks` MUST pass over every supported - /// network when the name resolves to the expected address on each. The + /// `checkRegisteredAddressesOnNetworks` MUST fork each network in turn and + /// pass when the name resolves to the expected address on all of them. The /// registry is made persistent so the same binding is present on every /// fork, which is the state the check exists to confirm. Fixed inputs /// rather than fuzzed: what varies here is the network, and every run forks - /// all five. - function testCheckRegisteredAddressesOnNetworksAllNetworks() external { - bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksAllNetworks"); + /// each one. + /// + /// Two networks rather than `supportedNetworks()`. What is under test is + /// that the loop visits every network it is given, which two prove as well + /// as five; the roster itself is `testSupportedNetworks`'s job. These are + /// the two networks the rest of this suite forks, so the test does not + /// depend on the reliability of RPC endpoints nothing else here touches. + function testCheckRegisteredAddressesOnNetworksEachNetwork() external { + bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksEachNetwork"); address account = address(0xf00); deployRegistryWithBinding(name, account); vm.makePersistent(LibAddressRegistry.ADDRESS_REGISTRY); + string[] memory networks = new string[](2); + networks[0] = LibRainDeploy.ARBITRUM_ONE; + networks[1] = LibRainDeploy.BASE; bytes32[] memory names = new bytes32[](1); names[0] = name; address[] memory expectedAddresses = new address[](1); expectedAddresses[0] = account; - LibRainDeploy.checkRegisteredAddressesOnNetworks( - vm, LibRainDeploy.supportedNetworks(), names, expectedAddresses - ); + LibRainDeploy.checkRegisteredAddressesOnNetworks(vm, networks, names, expectedAddresses); } /// `checkRegisteredAddressesOnNetworks` MUST fail on the network that From bf5ac30bb9af3627140316299a16b0cd4dc40a37 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:17:24 +0000 Subject: [PATCH 03/29] docs: Zoltu deploys with CREATE2 under a zero salt, not CREATE with a nonce The address is a pure function of the creation code, which is the property the whole library rests on; describing it as a nonce-based CREATE would lead a consumer to derive the wrong address. Also lists Base Sepolia, which supportedNetworks() has returned all along. --- CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b33f19d..1ab0f42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ code in this repository. rain.deploy is a Solidity library for deploying Rain Protocol contracts via the Zoltu deterministic deployment proxy to multiple EVM networks. It ensures -identical contract addresses across all supported chains (Arbitrum, Base, Flare, -Polygon) because the Zoltu proxy is deployed at the same address on every chain -and uses CREATE with a predictable nonce. +identical contract addresses across all supported chains (Arbitrum, Base, Base +Sepolia, Flare, Polygon) because the Zoltu proxy is deployed at the same address +on every chain and deploys with `CREATE2` over its calldata under a zero salt, +so a contract's address is a pure function of its creation code. ## Build & Development From 605c1c2e524e65ee2e34b4c41029383316940a66 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:44:55 +0000 Subject: [PATCH 04/29] feat(registry): mutable bindings, post-deploy verification, concrete moves here Three changes that only make sense together. WRITE-ONCE WAS WRONG. The name a consumer resolves is in its creation code, so a binding welded to one address forever cannot express an ordinary owning-Safe rotation: it would need a new name, hence new creation code and a new deterministic address. That is the exact problem the registry exists to remove, relocated. What write-once bought was narrower than it looked - it protected bindings on chains already in use from a compromised root, and never protected a fresh chain, since an attacker registers the name there first either way. Root may now re-register a name. Everything else stands: immutable root, reverts on unset, and `register` still rejects the zero address, which still matters because unset reads as zero. THE GATE MOVES AFTER THE DEPLOY. A pre-deploy check against a mutable registry is TOCTOU and guarantees nothing. Deploy first, verify, then migrate onto it - so verification reads the value the deployed contract already snapshotted in its constructor, which is settled state and cannot move underneath the check. A poisoned deploy is then a burned deterministic address found before anything depends on it, rather than a compromise. `checkRegisteredAddresses{,OnNetworks}` are replaced by `checkResolvedAddresses{,OnNetworks}`, which are deliberately source-agnostic: only the consumer knows where it stored what it resolved, so the consumer supplies the reads and this library supplies the fork loop and the comparison. Re-reading the registry post-deploy would assert a value that can move rather than the value the deployment actually took. THE CONCRETE MOVES INTO THIS REPO. The address and codehash are a function of the creation code, which is a function of the compiler settings that compiled it. With the concrete, the settings and the pins all here, there is no boundary across which they can silently diverge and nothing depends on `rain-factory-deploy` for them. `foundry.toml` pins solc/optimizer/evm_version exactly for that reason, and the release lifecycle moves to `rainix-tag-release` to match what this repo now is: a repo carrying a deployed concrete whose pins consumers rely on. Already-published versions stay published and consumers pin exact versions, so nothing downstream changes. Slither's low-level-calls detector is excluded: the post-deploy read is a staticcall with consumer-supplied calldata by design, its success and return length are both checked, and there is no typed alternative when the consumer is the one who knows what to read. --- .github/workflows/package-release.yaml | 22 +- CLAUDE.md | 45 ++- README.md | 68 ++-- foundry.toml | 24 +- remappings.txt | 1 + script/BuildPointers.sol | 124 +++++++ slither.config.json | 2 +- src/concrete/AddressRegistry.sol | 73 +++++ src/interface/IAddressRegistryV1.sol | 73 +++-- src/lib/LibAddressRegistry.sol | 41 +-- src/lib/LibAddressRegistryDeploy.sol | 37 +++ src/lib/LibRainDeploy.sol | 112 ++++--- test/concrete/MockResolvedOwner.sol | 21 ++ test/lib/AddressRegistryPins.sol | 29 -- .../concrete/AddressRegistryDeployPins.t.sol | 49 +++ test/src/concrete/AddressRegistryGet.t.sol | 75 +++++ .../concrete/AddressRegistryRegister.t.sol | 195 +++++++++++ test/src/lib/LibAddressRegistry.t.sol | 56 ++-- test/src/lib/LibRainDeploy.t.sol | 306 +++++++++++------- 19 files changed, 1041 insertions(+), 312 deletions(-) create mode 100644 script/BuildPointers.sol create mode 100644 src/concrete/AddressRegistry.sol create mode 100644 src/lib/LibAddressRegistryDeploy.sol create mode 100644 test/concrete/MockResolvedOwner.sol delete mode 100644 test/lib/AddressRegistryPins.sol create mode 100644 test/src/concrete/AddressRegistryDeployPins.t.sol create mode 100644 test/src/concrete/AddressRegistryGet.t.sol create mode 100644 test/src/concrete/AddressRegistryRegister.t.sol diff --git a/.github/workflows/package-release.yaml b/.github/workflows/package-release.yaml index d0a1ba8..4b2999f 100644 --- a/.github/workflows/package-release.yaml +++ b/.github/workflows/package-release.yaml @@ -1,11 +1,27 @@ name: Package Release +# Deploy repo: a manual `sol-v*` tag is the sole release trigger. This repo now +# carries a deployed concrete (`AddressRegistry`) whose address + codehash +# consumers pin, which is exactly the shape rainix-tag-release exists for and +# exactly the shape rainix-autopublish's merge-driven, next-version lifecycle is +# wrong for: autopublish bumps [package].version on every merge while the frozen +# deploy tag only advances at deploy time. +# +# The tag names the version; rainix-tag-release regenerates the snapshot for it, +# verifies the live chains match the fresh pins, publishes rain-deploy to +# Soldeer, and commits the frozen snapshot back to main. The on-chain deploy is +# separate and manual, run before tagging; this never broadcasts. +# +# Switching lifecycles retracts nothing: every version already published stays +# published, and consumers pin exact versions, so this changes who cuts a +# release and nothing about how anyone consumes one. on: push: - branches: - - main + tags: + - sol-v* jobs: release: - uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main + uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main with: soldeer-package: rain-deploy + snapshot-generate-cmd: forge script ./script/BuildPointers.sol && forge fmt secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index 1ab0f42..d98c437 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,21 +71,36 @@ These are referenced in `foundry.toml` under `[rpc_endpoints]`. as foundry RPC config aliases) - `isStartBlock(...)` / `findDeployBlock(...)` — binary search a fork's history for the block a contract first appears at -- `checkRegisteredAddresses(...)` — asserts names resolve to their expected - addresses in the address registry, on the currently selected fork -- `checkRegisteredAddressesOnNetworks(...)` — runs that check on every network, - so a deployment's resolved addresses are gated across the whole target set - here rather than in each consumer's deploy script +- `checkResolvedAddresses(...)` — asserts an already-deployed contract holds the + addresses the deployment expected, on the currently selected fork, via + consumer-supplied static reads +- `checkResolvedAddressesOnNetworks(...)` — runs that check on every network. It + runs AFTER the deploy, against state the deployment has already settled, which + is the only point at which such a check means anything: registry bindings are + mutable, so a pre-deploy check would read a source that can change before the + constructor that consumes it - `deployToNetworks(...)` — forks each network, verifies the factory and dependencies, deploys via Zoltu, verifies address and code hash - `deployAndBroadcast(...)` — the main entry point: derives the deployer from a private key, then `deployToNetworks` **`src/interface/IAddressRegistryV1.sol`** — the address registry interface: an -immutable root binds a `bytes32` name to an address once and forever -(`register`), anyone reads a bound name (`get`), and reading an unbound name -reverts. The implementation is `AddressRegistry` in -[rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy). +immutable root binds a `bytes32` name to an address (`register`), anyone reads a +bound name (`get`), and reading an unbound name reverts. Bindings are mutable so +an owning multisig can rotate without moving any consumer's deterministic +address; a consumer resolves once in its constructor and stores the answer, so a +re-binding never moves anything already deployed. + +**`src/concrete/AddressRegistry.sol`** — the implementation. Two functions and +nothing else. `ADDRESS_REGISTRY_ROOT` is a compile-time constant and therefore +part of the creation code, so changing it moves the deterministic address and +code hash. + +**`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the +creation code this repo compiles under this repo's own settings and checked +against it by `AddressRegistryDeployPinsTest`. Hand-written until the first +`sol-v*` release generates it from `src/generated//`; no snapshot is frozen +while the root is a placeholder, because that directory is append-only. **`src/lib/LibAddressRegistry.sol`** — reads that registry at its deterministic address, verifying its code hash first, exactly as `LibRainDeploy` verifies @@ -107,9 +122,15 @@ expected addresses, expected code hashes, and dependency lists. (contract addresses) are verified to have code on-chain. - **Idempotent deploys**: If code already exists at the expected address, deployment is skipped for that network. -- **Write-once bindings**: registry bindings can never move, which is what makes - checking them before a deploy meaningful rather than a race, and what lets the - cross-network check be a pre-flight over every network. +- **Resolve once, verify after**: registry bindings are mutable, so the + meaningful check is not "does the registry say what I expect" before a deploy + but "does the deployed contract hold what I expect" after one. A consumer + resolves in its constructor; the deployment is then verified across every + network before anything migrates onto it. +- **Deploy-repo lifecycle**: a manual `sol-v*` tag is the sole release trigger + (`rainix-tag-release`), because this repo carries a deployed concrete whose + pins consumers rely on. `[package].version` is the LAST released version and + moves only in lockstep with its snapshot. ## License diff --git a/README.md b/README.md index 38b3da0..b94fa54 100644 --- a/README.md +++ b/README.md @@ -28,31 +28,55 @@ Approach: and against the chain after: silent failures fail loudly. - Bytecode integrity checks (e.g. via the Rain Extrospection lib) supported post-deploy. -- A write-once address registry, read at run time rather than compiled in, and - gated across every target network before a deploy. +- An address registry, read at run time rather than compiled into creation code, + and a post-deploy check that every target network's deployment took the + address it was supposed to. ## Address registry -`IAddressRegistryV1` binds an opaque `bytes32` name to an address. An immutable -root authority binds a name that is unbound; nothing, root included, can change -one after; and reading an unbound name reverts rather than answering with the -zero address. There is no rotation, no removal and no admin surface, because a -binding that can move is not worth checking before a deploy. - -`LibAddressRegistry.resolve` reads it, verifying the registry's code hash first, -the same way `LibRainDeploy` verifies the Zoltu factory's. It resolves a name to -an address and stops there — what a consumer does with the address, and when, is -the consumer's business. - -`LibRainDeploy.checkRegisteredAddressesOnNetworks` is the deploy-time gate: -every name must resolve to the address the deployment expects, on every target -network, before anything is broadcast. Because bindings are write-once, that -pre-flight is exactly as strong as checking inline — an answer that exists -cannot change, and one that does not exist reverts. - -The implementation, `AddressRegistry`, lives in -[rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy); -`LibAddressRegistry` pins its deterministic address and code hash. +`AddressRegistry` binds an opaque `bytes32` name to an address. An immutable +root authority binds a name, anyone reads a bound name, and reading an unbound +name reverts rather than answering with the zero address. There is no removal, +no upgrade and no authority besides root. + +Bindings are **mutable**, because the addresses they name are. Rotating an +owning multisig is ordinary business and has to be expressible without moving +anybody's deterministic address — which a binding welded to one address forever +would make impossible, because the name is in the consumer's creation code, so a +new name means new creation code and a new address. That is the problem the +registry exists to remove, not a property worth keeping. + +Mutability costs nothing already deployed. A consumer resolves a name **once**, +in its constructor, and stores the answer; it never reads the registry again. So +re-binding a name changes what the _next_ deployment resolves and nothing else, +which makes a rotation a deliberate migration rather than a silent change to +live contracts. + +`LibAddressRegistry.resolve` is the read, verifying the registry's code hash +first, the same way `LibRainDeploy` verifies the Zoltu factory's. It resolves a +name to an address and stops there — what a consumer does with the address, and +when, is the consumer's business. + +`LibRainDeploy.checkResolvedAddressesOnNetworks` is the **post-deploy** +verification: on every target network, the deployed contract must hold the +address the deployment expected. It runs after the deploy and before anything +depends on it, against state the deployment has already settled, so nothing it +reads can move underneath it. The same check run beforehand would be worth +nothing against a mutable source. A network where the deployment took something +else is a burned deterministic address, found while nothing points at it yet. + +Only the consumer knows where it stored what it resolved, so the consumer +supplies the reads (`abi.encodeCall(IOwnable.owner, ())` and the like) and this +library supplies the fork loop and the comparison. + +## Releases + +This is a deploy repo: it carries a deployed concrete whose address and codehash +consumers pin, so releases are **manual `sol-v*` tags**, not merges. +`[package].version` is the LAST released version, naming the current +`src/generated//` snapshot, and only a release moves it. Every version +published under the previous merge-driven lifecycle stays published; consumers +pin exact versions and are unaffected. ## Install diff --git a/foundry.toml b/foundry.toml index 3152e5c..3375b40 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,9 @@ [package] name = "rain-deploy" -version = "0.1.6" +# Deploy repo: this is the LAST released version, naming the current +# src/generated// snapshot, not a next-version slot. A normal PR does not +# bump it; only a `sol-v*` tag release moves it, in lockstep with the snapshot. +version = "0.1.5" # SPDX-License-Identifier: LicenseRef-DCL-1.0 # SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd @@ -9,11 +12,30 @@ version = "0.1.6" src = "src" out = "out" libs = ["dependencies"] + +# This repo compiles a contract whose deterministic deploy address and code hash +# are pinned in LibAddressRegistryDeploy, and both are a pure function of the +# creation code, which is a function of these settings. They are pinned exactly +# rather than floated so the pins cannot move under a compiler or default-target +# change, and they match the settings the org's other deploy repos use. +solc = "0.8.25" +optimizer = true +optimizer_runs = 100000 +evm_version = "cancun" cbor_metadata = false bytecode_hash = "none" +# BuildPointers reads the version from foundry.toml and writes the generated +# per-tag snapshots + the current-pin lib under src/. Nothing else in this repo +# touches the filesystem. +fs_permissions = [ + { access = "read", path = "./foundry.toml" }, + { access = "read-write", path = "./src" }, +] + [dependencies] forge-std = "1.16.1" +rain-sol-codegen = "0.1.0" [soldeer] recursive_deps = false diff --git a/remappings.txt b/remappings.txt index f4f4742..905398f 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ forge-std-1.16.1/=dependencies/forge-std-1.16.1/ +rain-sol-codegen-0.1.0/=dependencies/rain-sol-codegen-0.1.0/ diff --git a/script/BuildPointers.sol b/script/BuildPointers.sol new file mode 100644 index 0000000..cd69bea --- /dev/null +++ b/script/BuildPointers.sol @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.0/src/lib/LibCodeGen.sol"; +import {LibFs} from "rain-sol-codegen-0.1.0/src/lib/LibFs.sol"; +import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; +import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; + +/// @title BuildPointers +/// @notice Generates the deterministic-deploy pins for `AddressRegistry`: +/// 1. A frozen per-release snapshot +/// `src/generated//AddressRegistry.pointers.sol` (`BYTECODE_HASH`, +/// `DEPLOYED_ADDRESS`, `CREATION_CODE`, `RUNTIME_CODE`) for the current +/// `deployTag()`. Historical tags are never regenerated; a release bump +/// writes a new `/` snapshot beside them. +/// 2. `src/lib/LibAddressRegistryDeploy.sol` — the current-release address and +/// codehash, aliased from the current `deployTag()` snapshot so that +/// snapshot stays the single source of truth (never a duplicated literal). +/// Kept in `src/lib` so consumers' import path is stable across releases. +/// +/// Run as `forge script script/BuildPointers.sol`. Wired into +/// `rainix-tag-release`'s `snapshot-generate-cmd`, so a release regenerates the +/// pins for the version the tag names. +contract BuildPointers is Script { + string constant GEN_LIB_PATH = "src/lib/LibAddressRegistryDeploy.sol"; + + // REUSE-IgnoreStart (the two SPDX lines below are the header EMITTED into the + // generated lib, not this script's own license — hide from reuse lint) + string constant GEN_SPDX_LICENSE = "// SPDX-License-Identifier: LicenseRef-DCL-1.0"; + string constant GEN_SPDX_COPYRIGHT = "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd"; + + // REUSE-IgnoreEnd + + /// @notice The canonical release tag. Read from `foundry.toml` + /// `[package].version` — the single source of truth — with dots converted to + /// underscores for the Solidity dir form (`0.1.6` -> `0_1_6`). + /// @return The tag in its Solidity directory form. + function deployTag() internal view returns (string memory) { + string memory version = vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version"); + bytes memory b = bytes(version); + bytes memory out = new bytes(b.length); + for (uint256 i = 0; i < b.length; i++) { + // forge-lint: disable-next-line(unsafe-typecast) + out[i] = b[i] == "." ? bytes1("_") : b[i]; + } + return string(out); + } + + /// @notice The generated `DEPLOYED_ADDRESS` constant declaration. + /// @param addr The deterministic deploy address. + /// @return The Solidity source for the constant. + function addressConstantString(address addr) internal pure returns (string memory) { + return string.concat( + "\n", + "/// @dev The deterministic deploy address of the contract when deployed via\n", + "/// the Zoltu factory.\n", + "address constant DEPLOYED_ADDRESS = address(", + vm.toString(addr), + ");\n" + ); + } + + function run() external { + LibRainDeploy.etchZoltuFactory(vm); + + // A fresh version slot has no `/` dir yet, and `vm.writeFile` + // won't create one. + vm.createDir(string.concat("src/generated/", deployTag()), true); + + bytes memory creationCode = type(AddressRegistry).creationCode; + address deployed = LibRainDeploy.deployZoltu(creationCode); + + // Frozen per-tag snapshot. + LibFs.buildFileForContract( + vm, + deployed, + string.concat(deployTag(), "/AddressRegistry"), + string.concat( + addressConstantString(deployed), + LibCodeGen.bytesConstantString( + vm, "/// @dev The creation bytecode of the contract.", "CREATION_CODE", creationCode + ), + LibCodeGen.bytesConstantString( + vm, "/// @dev The runtime bytecode of the contract.", "RUNTIME_CODE", deployed.code + ) + ) + ); + + // Current-release pin lib. + genLibAddressRegistryDeploy(); + } + + /// @notice (Re)generate `src/lib/LibAddressRegistryDeploy.sol`, aliasing the + /// current `deployTag()` snapshot's `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as + /// the current-release constants — the snapshot stays the single source of + /// truth (never a duplicated literal). Emitted line-by-line to match the + /// generated-file convention. + function genLibAddressRegistryDeploy() internal { + string memory importPath = string.concat("../generated/", deployTag(), "/AddressRegistry.pointers.sol"); + vm.writeFile(GEN_LIB_PATH, ""); + vm.writeLine(GEN_LIB_PATH, GEN_SPDX_LICENSE); + vm.writeLine(GEN_LIB_PATH, GEN_SPDX_COPYRIGHT); + vm.writeLine(GEN_LIB_PATH, "pragma solidity ^0.8.25;"); + vm.writeLine(GEN_LIB_PATH, ""); + vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.sol"); + vm.writeLine(GEN_LIB_PATH, ""); + vm.writeLine(GEN_LIB_PATH, "import {"); + vm.writeLine(GEN_LIB_PATH, " DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR,"); + vm.writeLine(GEN_LIB_PATH, " BYTECODE_HASH as ADDRESS_REGISTRY_HASH"); + vm.writeLine(GEN_LIB_PATH, string.concat("} from \"", importPath, "\";")); + vm.writeLine(GEN_LIB_PATH, ""); + vm.writeLine(GEN_LIB_PATH, "/// @title LibAddressRegistryDeploy"); + vm.writeLine(GEN_LIB_PATH, "/// @notice The deterministic Zoltu deploy address and code hash of the current"); + vm.writeLine(GEN_LIB_PATH, "/// `AddressRegistry` release, aliased from the frozen per-release snapshot in"); + vm.writeLine(GEN_LIB_PATH, "/// `src/generated//AddressRegistry.pointers.sol` so that snapshot stays the"); + vm.writeLine(GEN_LIB_PATH, "/// single source of truth."); + vm.writeLine(GEN_LIB_PATH, "library LibAddressRegistryDeploy {"); + vm.writeLine(GEN_LIB_PATH, " address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR;"); + vm.writeLine(GEN_LIB_PATH, " bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH;"); + vm.writeLine(GEN_LIB_PATH, "}"); + } +} diff --git a/slither.config.json b/slither.config.json index 70f3181..745db00 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { "filter_paths": "dependencies/forge-std-", - "detectors_to_exclude": "assembly" + "detectors_to_exclude": "assembly,low-level-calls" } diff --git a/src/concrete/AddressRegistry.sol b/src/concrete/AddressRegistry.sol new file mode 100644 index 0000000..b1e6581 --- /dev/null +++ b/src/concrete/AddressRegistry.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; + +/// @dev PLACEHOLDER ROOT AUTHORITY. THIS IS NOT A REAL ROOT. +/// +/// The only account that may bind a name. It is a compile-time constant, not +/// storage, so it can never be rotated, and it is part of the creation code, so +/// changing it changes the deterministic deploy address and code hash of +/// `AddressRegistry` on every network. +/// +/// A human MUST replace this value with the intended root before any deploy-pin +/// snapshot is generated for this contract, and the pins in `rain-deploy`'s +/// `LibAddressRegistry` MUST be regenerated from the resulting creation code. +address constant ADDRESS_REGISTRY_ROOT = address(0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD); + +/// @title AddressRegistry +/// @notice The whole of `IAddressRegistryV1`: an immutable root authority binds +/// a `bytes32` name, anyone reads a bound name, and a read of an unbound name +/// reverts. +/// +/// There is deliberately nothing else. No removal, no upgrade, no pause, and no +/// authority besides root — root is a compile-time constant, so it cannot even +/// hand itself over. +/// +/// A binding is mutable because the address it names is. Rotating an owning +/// multisig is ordinary business and has to be expressible without moving +/// anybody's deterministic address, which a binding welded to one address +/// forever would make impossible: the name is in the consumer's creation code, +/// so a new name means new creation code and a new address. +/// +/// Mutability costs nothing already deployed. A consumer resolves a name once, +/// in its constructor, and never reads the registry again, so re-binding a name +/// changes what the next deployment resolves and nothing else — a rotation is a +/// deliberate migration, never a silent change to live contracts. +/// +/// The storage mapping is `internal` rather than `public`: a public mapping's +/// generated getter answers an unbound name with the zero address, which is +/// exactly the silent failure `get` reverts to prevent. +contract AddressRegistry is IAddressRegistryV1 { + /// The bindings. Not `public`: the only reader is `get`, which reverts on an + /// unbound name. A name maps to the zero address if and only if it is + /// unbound, which is why `register` rejects the zero address. + mapping(bytes32 name => address account) internal sAddresses; + + /// @inheritdoc IAddressRegistryV1 + function register(bytes32 name, address account) external { + if (msg.sender != ADDRESS_REGISTRY_ROOT) { + revert NotRoot(msg.sender); + } + // Rejected so that a name can never be both bound and unreadable. There + // is deliberately no way to unbind a name; the nearest thing is + // re-binding it to something inert. + if (account == address(0)) { + revert ZeroAccount(name); + } + sAddresses[name] = account; + emit Register(name, account); + } + + /// @inheritdoc IAddressRegistryV1 + /// @dev Returns whatever root has bound most recently. A caller that needs + /// an answer that cannot move reads once and stores it, which is what a + /// consumer resolving a name in its constructor does. + function get(bytes32 name) external view returns (address account) { + account = sAddresses[name]; + if (account == address(0)) { + revert NameNotRegistered(name); + } + } +} diff --git a/src/interface/IAddressRegistryV1.sol b/src/interface/IAddressRegistryV1.sol index d10f1b1..84c925a 100644 --- a/src/interface/IAddressRegistryV1.sol +++ b/src/interface/IAddressRegistryV1.sol @@ -4,42 +4,46 @@ pragma solidity ^0.8.25; /// @title IAddressRegistryV1 /// @notice A registry of `bytes32` names to addresses with exactly two -/// operations: an immutable root authority binds a name that is unbound, and -/// anyone reads a name that is bound. There is no rotation, no removal, no -/// upgrade and no admin surface, and an implementation MUST NOT add any: the -/// value of the registry is that a binding, once made, is a constant. +/// operations: an immutable root authority binds a name (`register`), and +/// anyone reads a bound name (`get`). Root may re-bind a name it has already +/// bound. There is no removal, no upgrade and no authority beyond root, and an +/// implementation MUST NOT add any. /// /// Names are opaque 32-byte values. This interface says nothing about how a /// name is derived — hashed from a string, a raw ASCII literal, a counter — and /// an implementation MUST NOT constrain it. Two callers agreeing on a name is /// entirely their business. /// -/// The write-once property is what makes a deploy-time check of a binding worth -/// anything. Against a mutable value the check would be a race, because the -/// value could move between the check and the read that consumes it. Here, once -/// `get` returns for a name, it returns the same address forever. +/// Bindings are mutable because the addresses they name are. Rotating an owning +/// multisig is ordinary business, and a binding that could never change would +/// make it impossible: the name a consumer resolves is in that consumer's +/// creation code, so a name welded to one address forever would force a new +/// name — and therefore new creation code and a new deterministic address — for +/// a routine rotation. That is the problem the registry exists to remove, not a +/// property worth keeping. /// -/// Compromising root therefore cannot change any existing binding. It can reach -/// a network nobody has deployed to yet and bind the intended names against -/// itself, burning them there, which forces a different name on that network -/// and moves addresses on that network only. That is a loud, per-network loss -/// of determinism, never a silent or retroactive change. +/// A binding moving never moves anything already deployed. A consumer resolves +/// a name once, at construction, and stores the answer; it never consults the +/// registry again. So re-binding a name changes what the *next* deployment +/// resolves and nothing else, which is exactly what makes a rotation a +/// deliberate migration rather than a silent, retroactive change to live +/// contracts. +/// +/// A compromised root therefore cannot touch anything deployed. It can point a +/// name at an address it controls, so that a deployment made after the +/// compromise snapshots that address. That is caught by verifying a deployment +/// after deploying it and before anything depends on it: the deployed contract +/// has already snapshotted the value, so checking it is checking settled state. +/// A poisoned deploy is a burned deterministic address, discovered before use. interface IAddressRegistryV1 { /// Thrown when an account that is not the root authority calls `register`. /// @param sender The `msg.sender` that was not root. error NotRoot(address sender); - /// Thrown when `register` is called for a name that is already bound. - /// Bindings are write-once, so this is thrown even for root, and even when - /// the account being registered is the account already bound. - /// @param name The name that is already bound. - /// @param account The address `name` is bound to. - error NameAlreadyRegistered(bytes32 name, address account); - /// Thrown when `register` is called with the zero address. The zero address /// is how an unbound name reads, so binding it would produce a name that is - /// both bound and unreadable, and that `register` would accept a second - /// time. + /// both bound and unreadable. A name cannot be unbound once bound; the + /// closest thing is binding it somewhere deliberately inert. /// @param name The name that was being bound to the zero address. error ZeroAccount(bytes32 name); @@ -48,34 +52,35 @@ interface IAddressRegistryV1 { /// @param name The name that is not bound. error NameNotRegistered(bytes32 name); - /// Emitted when `name` is bound to `account`. Bindings are write-once, so - /// exactly one `Register` is ever emitted per name, and the log is the - /// complete enumeration of the registry — there is no other way to discover - /// a binding without already knowing the name. Both parameters are indexed - /// for that reason: the log has to answer "what is this name bound to" and - /// "what did root bind to this address" without a full scan. + /// Emitted every time `name` is bound, including when it is re-bound. The + /// log is the complete history of the registry and the only way to discover + /// a binding without already knowing the name; the most recent `Register` + /// for a name is its current binding. /// @param name The name that was bound. /// @param account The address `name` was bound to. event Register(bytes32 indexed name, address indexed account); - /// Binds `name` to `account`, permanently. + /// Binds `name` to `account`, replacing any address it is already bound to. /// /// The implementation MUST revert `NotRoot` unless the caller is the root - /// authority, MUST revert `ZeroAccount` if `account` is the zero address, - /// and MUST revert `NameAlreadyRegistered` if `name` is already bound — - /// including when the caller is root and including when `account` is the - /// address already bound. On success it MUST emit `Register`. + /// authority, and MUST revert `ZeroAccount` if `account` is the zero + /// address. On success it MUST emit `Register`. /// @param name The name to bind. /// @param account The address to bind it to. function register(bytes32 name, address account) external; - /// The address `name` is bound to. + /// The address `name` is currently bound to. /// /// The implementation MUST revert `NameNotRegistered` when `name` is /// unbound, rather than returning the zero address, so that no caller has /// to remember to check. It MUST NOT expose any other reader that returns /// the zero address for an unbound name, as that reintroduces exactly the /// mistake this reverting read exists to prevent. + /// + /// A caller that needs an answer that cannot move MUST read once and store + /// the result, which is what a consumer resolving a name in its constructor + /// does. Reading at the point of use instead means reading whatever root + /// has bound most recently. /// @param name The name to read. /// @return account The address bound to `name`. Never the zero address. function get(bytes32 name) external view returns (address account); diff --git a/src/lib/LibAddressRegistry.sol b/src/lib/LibAddressRegistry.sol index b18f7c6..6d53509 100644 --- a/src/lib/LibAddressRegistry.sol +++ b/src/lib/LibAddressRegistry.sol @@ -3,9 +3,10 @@ pragma solidity ^0.8.25; import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; +import {LibAddressRegistryDeploy} from "./LibAddressRegistryDeploy.sol"; /// @title LibAddressRegistry -/// @notice Reads the `IAddressRegistryV1` deployed at a single deterministic +/// @notice Reads the `AddressRegistry` deployed at a single deterministic /// address on every network, verifying the registry's code hash first, exactly /// as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH` before using the Zoltu /// factory. An address alone says nothing on a chain the caller has not @@ -16,32 +17,22 @@ import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; /// resolves a name for, and when — an owner set in a constructor or an /// initializer, under `Ownable` or RBAC or nothing at all — is entirely the /// consumer's business and none of this library's. +/// +/// Bindings are mutable, so `resolve` answers with whatever root has bound most +/// recently. A caller that needs an answer that cannot move afterwards resolves +/// once, in its constructor, and stores the result; it must not re-read at the +/// point of use. That single read at construction is what makes a deployment +/// verifiable after the fact: the value is settled the moment the contract +/// exists, and no later re-binding can move it. library LibAddressRegistry { /// Thrown when the code at the registry address is not the registry this /// library was compiled against. An address with no code hits this too: an - /// empty account's code hash is zero (or the hash of empty code), never the - /// expected value. + /// empty account's code hash is zero, never the expected value. /// @param expectedCodeHash The code hash of the pinned registry. /// @param actualCodeHash The code hash actually found at the address. error UnexpectedAddressRegistryCodeHash(bytes32 expectedCodeHash, bytes32 actualCodeHash); - /// The deterministic Zoltu deploy address of `AddressRegistry`, the same on - /// every network. - /// - /// Derived from that contract's creation code, not observed from a chain: - /// the Zoltu factory is `CREATE2` over its calldata with a zero salt, so the - /// address is a pure function of the creation code - /// (`LibRainDeploy.zoltuAddress`). The root authority is a constant in that - /// creation code, so changing the root moves this address, and both this and - /// `ADDRESS_REGISTRY_CODEHASH` MUST be re-derived whenever it changes. - address constant ADDRESS_REGISTRY = 0x619e47868cE4a9AEbBD6444c9385f1558c79ED52; - - /// The code hash of `AddressRegistry` once deployed, i.e. `keccak256` over - /// the runtime code its creation code leaves behind. Derived from the same - /// compilation as `ADDRESS_REGISTRY`, and moves with it. - bytes32 constant ADDRESS_REGISTRY_CODEHASH = 0x01e8bf67abc9d4b4abe2d39c66c07c1b02e39bdf559c694f94f5853bad6394d8; - - /// The address a name is bound to in the registry. + /// The address `name` is currently bound to in the registry. /// /// Verifies the registry's code hash before reading, so a chain where the /// registry is absent, or where something else occupies its address, is a @@ -52,10 +43,12 @@ library LibAddressRegistry { /// about how it was derived. /// @return The address bound to `name`. function resolve(bytes32 name) internal view returns (address) { - bytes32 actualCodeHash = ADDRESS_REGISTRY.codehash; - if (actualCodeHash != ADDRESS_REGISTRY_CODEHASH) { - revert UnexpectedAddressRegistryCodeHash(ADDRESS_REGISTRY_CODEHASH, actualCodeHash); + bytes32 actualCodeHash = LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS.codehash; + if (actualCodeHash != LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH) { + revert UnexpectedAddressRegistryCodeHash( + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, actualCodeHash + ); } - return IAddressRegistryV1(ADDRESS_REGISTRY).get(name); + return IAddressRegistryV1(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS).get(name); } } diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol new file mode 100644 index 0000000..97e24e8 --- /dev/null +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title LibAddressRegistryDeploy +/// @notice The deterministic Zoltu deploy address and code hash of +/// `AddressRegistry`. The Zoltu factory is `CREATE2` over its calldata with a +/// zero salt, so the address is a pure function of the creation code and is +/// identical on every network. +/// +/// Both values are derived from the creation code this repo compiles, under +/// this repo's own compiler settings, and are checked against it by +/// `AddressRegistryDeployPinsTest` — the contract, the settings that compile it +/// and the pins that describe it are all here, so there is no boundary across +/// which they can silently diverge. +/// +/// The root authority is a constant in that creation code, so changing the root +/// moves both values. +/// +/// HAND-WRITTEN FOR NOW. From the first `sol-v*` release this file is +/// regenerated by `script/BuildPointers.sol`, aliasing the frozen +/// `src/generated//AddressRegistry.pointers.sol` snapshot so that snapshot +/// is the single source of truth. It already lives at the import path the +/// generated version will occupy, so consumers' imports do not move. No +/// snapshot is frozen yet because `ADDRESS_REGISTRY_ROOT` is still a +/// placeholder and `src/generated//` is append-only: a snapshot written +/// now could never be corrected. +library LibAddressRegistryDeploy { + /// @dev The deterministic deploy address of `AddressRegistry` when deployed + /// via the Zoltu factory. + address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = 0x0B8CAaDADF7c53a1b0Af8A7A8E7F3ca90DE517d6; + + /// @dev The code hash of `AddressRegistry` once deployed, i.e. `keccak256` + /// over the runtime code its creation code leaves behind. + bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = + 0xd9a6a2f03c1e1851becfedba819a436dcccc81c40ac8df02be6815f0b261d042; +} diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index a34ca6a..6180b2b 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -4,7 +4,6 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.1/src/Vm.sol"; import {console2} from "forge-std-1.16.1/src/console2.sol"; -import {LibAddressRegistry} from "./LibAddressRegistry.sol"; /// @title LibRainDeploy /// Library for deploying contracts via the Zoltu factory across all the networks @@ -41,13 +40,17 @@ library LibRainDeploy { /// the deploy may have happened before the search range. error DeployedBeforeStartBlock(address target, uint256 startBlock); - /// Thrown when a registry name resolves to something other than the address - /// the deployment expects on a network. - error UnexpectedRegisteredAddress(string network, bytes32 name, address expected, address actual); + /// Thrown when a deployed contract holds an address other than the one the + /// deployment expects, on a network. + error UnexpectedResolvedAddress(string network, address target, uint256 index, address expected, address actual); - /// Thrown when the names and expected addresses of a registry check do not - /// pair up. - error RegisteredAddressesLengthMismatch(uint256 namesLength, uint256 expectedAddressesLength); + /// Thrown when the read calls and expected addresses of a post-deploy check + /// do not pair up. + error ResolvedAddressesLengthMismatch(uint256 readCallsLength, uint256 expectedAddressesLength); + + /// Thrown when a post-deploy read reverts, or answers with something that is + /// not a single address-sized word. + error ResolvedAddressReadFailed(string network, address target, uint256 index, bytes returnData); /// Zoltu factory is the same on every network. address constant ZOLTU_FACTORY = 0x7A0D94F55792C434d74a40883C6ed8545E406D12; @@ -207,48 +210,75 @@ library LibRainDeploy { return networks; } - /// Asserts that each name resolves, in the address registry, to the address - /// the deployment expects, on whichever network is currently selected. - /// Verifying the registry's code hash is `LibAddressRegistry.resolve`'s job, - /// and an unbound name reverts there rather than resolving to nothing, so - /// every way this can be wrong is a revert. + /// Asserts that an already-deployed contract holds the addresses the + /// deployment expects, on whichever network is currently selected. + /// + /// This runs AFTER the deploy, deliberately. What it checks is state the + /// deployed contract has already settled — a value it resolved once, in its + /// constructor, and stored — so nothing it reads can move underneath it. The + /// same check run BEFORE a deploy would be worth nothing: it would read a + /// source that can change between the check and the constructor that + /// consumes it. + /// + /// It is deliberately source-agnostic. It says the deployed contract holds + /// the expected address, not where that address came from, because the + /// address registry is only one way a deployment acquires one, and because + /// re-reading the registry here would assert a value that can move rather + /// than the value this deployment actually took. + /// + /// Only the consumer knows where it stored what it resolved, so the consumer + /// supplies the reads. Each entry in `readCalls` is static-called against + /// `target` and MUST answer with exactly one address. /// @param network The network name, for the error only. - /// @param names The names to resolve. - /// @param expectedAddresses The address each name MUST resolve to, - /// positionally paired with `names`. - function checkRegisteredAddresses(string memory network, bytes32[] memory names, address[] memory expectedAddresses) - internal - view - { - if (names.length != expectedAddresses.length) { - revert RegisteredAddressesLengthMismatch(names.length, expectedAddresses.length); + /// @param target The deployed contract to read. + /// @param readCalls The calldata for each read, e.g. + /// `abi.encodeCall(IOwnable.owner, ())`. + /// @param expectedAddresses The address each read MUST answer with, + /// positionally paired with `readCalls`. + function checkResolvedAddresses( + string memory network, + address target, + bytes[] memory readCalls, + address[] memory expectedAddresses + ) internal view { + if (readCalls.length != expectedAddresses.length) { + revert ResolvedAddressesLengthMismatch(readCalls.length, expectedAddresses.length); } - for (uint256 i = 0; i < names.length; i++) { - address actual = LibAddressRegistry.resolve(names[i]); + for (uint256 i = 0; i < readCalls.length; i++) { + (bool success, bytes memory returnData) = target.staticcall(readCalls[i]); + // A read that reverts, answers nothing (no code at `target`), or + // answers something that is not one word cannot be compared, and is + // never a pass. + if (!success || returnData.length != 0x20) { + revert ResolvedAddressReadFailed(network, target, i, returnData); + } + address actual = abi.decode(returnData, (address)); if (actual != expectedAddresses[i]) { - revert UnexpectedRegisteredAddress(network, names[i], expectedAddresses[i], actual); + revert UnexpectedResolvedAddress(network, target, i, expectedAddresses[i], actual); } } } - /// Runs `checkRegisteredAddresses` over every network, so a deployment - /// asserts its resolved addresses agree across the whole target set here, - /// rather than every consumer's deploy script forking the networks itself. + /// Runs `checkResolvedAddresses` on every network, so a deployment verifies + /// itself across the whole target set here rather than in every consumer's + /// deploy script. /// - /// Registry bindings are write-once, so this is a pre-flight rather than a - /// race: a name that resolves here cannot resolve differently later, and a - /// name that is unbound here reverts here. Checking every network before - /// deploying to any of them means a network that disagrees stops the - /// deployment instead of leaving it half-applied. + /// Run this after `deployAndBroadcast` and before anything depends on the + /// deployment. A network where the deployed contract holds something other + /// than expected is a burned deterministic address, found while nothing + /// points at it yet — which is the whole reason to verify before migrating + /// onto a deployment rather than trusting it. /// @param vm The Vm instance to use for forking. /// @param networks The list of network names to check. - /// @param names The names to resolve on each network. - /// @param expectedAddresses The address each name MUST resolve to, - /// positionally paired with `names`. - function checkRegisteredAddressesOnNetworks( + /// @param target The deployed contract to read on each network. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with, + /// positionally paired with `readCalls`. + function checkResolvedAddressesOnNetworks( Vm vm, string[] memory networks, - bytes32[] memory names, + address target, + bytes[] memory readCalls, address[] memory expectedAddresses ) internal { if (networks.length == 0) { @@ -256,16 +286,16 @@ library LibRainDeploy { } // Checked before any fork so a mispaired call fails immediately rather // than after an RPC round trip. - if (names.length != expectedAddresses.length) { - revert RegisteredAddressesLengthMismatch(names.length, expectedAddresses.length); + if (readCalls.length != expectedAddresses.length) { + revert ResolvedAddressesLengthMismatch(readCalls.length, expectedAddresses.length); } for (uint256 i = 0; i < networks.length; i++) { // createSelectFork returns a fork id that is not needed here; bind // and reference it so the unused-return lint stays satisfied. uint256 forkId = vm.createSelectFork(networks[i]); (forkId); - console2.log("Checking registered addresses on network:", networks[i]); - checkRegisteredAddresses(networks[i], names, expectedAddresses); + console2.log("Checking resolved addresses on network:", networks[i]); + checkResolvedAddresses(networks[i], target, readCalls, expectedAddresses); } } diff --git a/test/concrete/MockResolvedOwner.sol b/test/concrete/MockResolvedOwner.sol new file mode 100644 index 0000000..d4f5dec --- /dev/null +++ b/test/concrete/MockResolvedOwner.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibAddressRegistry} from "../../src/lib/LibAddressRegistry.sol"; + +/// @title MockResolvedOwner +/// @notice A consumer in the shape the address registry is designed for: it +/// resolves a name exactly once, in its constructor, and stores the answer in an +/// immutable. It never reads the registry again, so a later re-binding of that +/// name cannot move what this contract holds — which is the property that makes +/// verifying a deployment after deploying it meaningful. +contract MockResolvedOwner { + /// The address the registry answered with at construction, and forever. + address public immutable iOwner; + + /// @param name The name to resolve, once. + constructor(bytes32 name) { + iOwner = LibAddressRegistry.resolve(name); + } +} diff --git a/test/lib/AddressRegistryPins.sol b/test/lib/AddressRegistryPins.sol deleted file mode 100644 index dccf716..0000000 --- a/test/lib/AddressRegistryPins.sol +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -/// @dev The creation code of `AddressRegistry`, the `IAddressRegistryV1` -/// implementation in -/// [rain.factory.deploy](https://github.com/rainlanguage/rain.factory.deploy), -/// as compiled by that repo (`solc 0.8.25`, optimizer on at 100,000 runs, evm -/// version `cancun`, no metadata): -/// -/// ```sh -/// forge inspect src/concrete/AddressRegistry.sol:AddressRegistry bytecode -/// ``` -/// -/// This library cannot depend on that repo — it depends on this one — so the -/// creation code is carried here instead, and it is what makes the pins in -/// `LibAddressRegistry` checkable rather than asserted: deploying this through -/// the Zoltu factory MUST land at `ADDRESS_REGISTRY` with -/// `ADDRESS_REGISTRY_CODEHASH`. If the registry's source changes — and the root -/// authority baked into it is part of that source — this blob and both pins -/// change together, and the test that deploys it says so. -bytes constant ADDRESS_REGISTRY_CREATION_CODE = - hex"6080604052348015600e575f80fd5b506102e68061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b610046366004610289565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b6100876100823660046102a0565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16801561020b576040517f7887e8c00000000000000000000000000000000000000000000000000000000081526004810184905273ffffffffffffffffffffffffffffffffffffffff821660248201526044016100e3565b5f8381526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff86169081179091559051909185917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a3505050565b5f60208284031215610299575f80fd5b5035919050565b5f80604083850312156102b1575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff811681146102db575f80fd5b80915050925092905056"; - -/// @dev The root authority baked into `ADDRESS_REGISTRY_CREATION_CODE`, needed -/// to bind a name in a test. Currently the placeholder `rain.factory.deploy` -/// carries until a human supplies the real root; when that happens the creation -/// code above and both `LibAddressRegistry` pins change with it. -address constant ADDRESS_REGISTRY_ROOT = address(0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD); diff --git a/test/src/concrete/AddressRegistryDeployPins.t.sol b/test/src/concrete/AddressRegistryDeployPins.t.sol new file mode 100644 index 0000000..7501714 --- /dev/null +++ b/test/src/concrete/AddressRegistryDeployPins.t.sol @@ -0,0 +1,49 @@ +// 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 "../../../src/lib/LibRainDeploy.sol"; +import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; +import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryDeployPinsTest +/// @notice `LibAddressRegistryDeploy` pins the deterministic address and code +/// hash of `AddressRegistry`, and `LibAddressRegistry` resolves names through +/// those pins. Both are a pure function of the creation code, which is a pure +/// function of this repo's compiler settings — and the contract, the settings +/// and the pins are all in this repo, so this suite closes the loop rather than +/// asserting across a boundary. +/// +/// The root authority is a constant in that creation code, so changing the root +/// moves both pins and turns this suite red until they follow. +contract AddressRegistryDeployPinsTest is Test { + /// The pins MUST be derivable from this source without deploying anything: + /// the Zoltu factory is `CREATE2` over its calldata with a zero salt, so the + /// address is a pure function of the creation code, and the code hash is + /// `keccak256` of the runtime code that creation code leaves behind. + function testAddressRegistryPinsDeriveFromThisSource() external pure { + assertEq( + LibRainDeploy.zoltuAddress(type(AddressRegistry).creationCode), + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS + ); + assertEq( + keccak256(type(AddressRegistry).runtimeCode), LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH + ); + } + + /// Actually deploying this contract's creation code through the Zoltu + /// factory MUST land at the pinned address with the pinned code hash, so the + /// derivation is checked against the factory rather than only against + /// itself. + function testAddressRegistryDeploysToPinnedAddress() external { + LibRainDeploy.etchZoltuFactory(vm); + + address deployed = LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode); + + assertEq(deployed, LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + assertEq(deployed.codehash, LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); + assertEq(keccak256(deployed.code), LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); + } +} diff --git a/test/src/concrete/AddressRegistryGet.t.sol b/test/src/concrete/AddressRegistryGet.t.sol new file mode 100644 index 0000000..7b6faed --- /dev/null +++ b/test/src/concrete/AddressRegistryGet.t.sol @@ -0,0 +1,75 @@ +// 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 {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryGetTest +/// @notice A test suite for `AddressRegistry.get`: it answers a bound name with +/// its address and an unbound name with a revert, and it is the only reader. +contract AddressRegistryGetTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// A read of an unbound name reverts rather than returning the zero + /// address, so a caller cannot proceed on a name nobody bound by forgetting + /// to check. + function testGetUnsetReverts(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// A read of a bound name returns exactly what was bound, and reading does + /// not consume or alter the binding. + function testGetReturnsRegistered(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + assertEq(sRegistry.get(name), account); + } + + /// Names are opaque: nothing about a name's bytes changes how it is stored + /// or read, including names a string-hashing convention would never + /// produce. + function testGetOpaqueNames(address account) external { + vm.assume(account != address(0)); + + bytes32[3] memory names = [bytes32(0), bytes32(uint256(1)), bytes32(type(uint256).max)]; + for (uint256 i = 0; i < names.length; i++) { + AddressRegistry registry = new AddressRegistry(); + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(names[i], account); + assertEq(registry.get(names[i]), account); + } + } + + /// `get` is the only reader. The bindings mapping is not `public`, so the + /// getter a `public` mapping would generate — which answers an unbound name + /// with the zero address, the exact silent failure `get` reverts to prevent + /// — does not exist. + function testGetNoGeneratedMappingGetter(bytes32 name) external { + (bool success,) = address(sRegistry).call(abi.encodeWithSignature("sAddresses(bytes32)", name)); + assertFalse(success); + } + + /// There is no other entry point at all: no fallback, no receive, and + /// nothing beyond the two `IAddressRegistryV1` functions, so an unknown + /// selector reverts instead of being silently absorbed. + function testGetNoOtherEntryPoint(bytes4 selector, bytes32 name) external { + vm.assume(selector != IAddressRegistryV1.get.selector); + vm.assume(selector != IAddressRegistryV1.register.selector); + + (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, name, address(this))); + assertFalse(success); + } +} diff --git a/test/src/concrete/AddressRegistryRegister.t.sol b/test/src/concrete/AddressRegistryRegister.t.sol new file mode 100644 index 0000000..f4bdf58 --- /dev/null +++ b/test/src/concrete/AddressRegistryRegister.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; + +import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryRegisterTest +/// @notice A test suite for `AddressRegistry.register`: who may bind a name, +/// that root may re-bind one, and what a binding may never become. +contract AddressRegistryRegisterTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// Only root may bind a name. Checked before the zero-address check, so a + /// non-root caller is rejected as `NotRoot` whatever it passes. + function testRegisterOnlyRoot(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + } + + /// Re-binding is root's alone. A name being already bound gives nobody else + /// authority over it, and the failed attempt leaves the binding untouched. + function testRegisterRebindOnlyRoot(address sender, bytes32 name, address bound, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + vm.assume(bound != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), bound); + } + + /// Root may re-bind a name to a different address, and the new binding is + /// what `get` answers with from then on. This is the rotation case: an + /// owning multisig changes without any consumer's name, creation code or + /// deterministic address moving. + function testRegisterRebind(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + assertEq(sRegistry.get(name), bound); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + assertEq(sRegistry.get(name), account); + } + + /// Re-binding a name to the address it already holds is allowed and is a + /// no-op on the binding. There is no special case for it in either + /// direction. + function testRegisterRebindSameAccount(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + } + + /// Re-binding survives any number of rotations, and only the most recent one + /// counts. + function testRegisterRebindRepeatedly(bytes32 name, address[] memory accounts) external { + vm.assume(accounts.length > 0); + for (uint256 i = 0; i < accounts.length; i++) { + vm.assume(accounts[i] != address(0)); + } + + for (uint256 i = 0; i < accounts.length; i++) { + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, accounts[i]); + assertEq(sRegistry.get(name), accounts[i]); + } + assertEq(sRegistry.get(name), accounts[accounts.length - 1]); + } + + /// The zero address is rejected. An unbound name reads as the zero address + /// internally, so binding it would produce a name that is bound but + /// unreadable. + function testRegisterZeroAccount(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.ZeroAccount.selector, name)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, address(0)); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// The zero address is rejected for a name that is already bound too, so + /// there is no way to unbind a name by re-binding it to zero — the existing + /// binding survives intact rather than the name reverting to "never bound". + function testRegisterZeroAccountCannotUnbind(bytes32 name, address bound) external { + vm.assume(bound != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.ZeroAccount.selector, name)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, address(0)); + + assertEq(sRegistry.get(name), bound); + } + + /// Names are independent: binding one says nothing about any other, and + /// re-binding one does not disturb another. + function testRegisterDistinctNames(bytes32 nameA, bytes32 nameB, address accountA, address accountB) external { + vm.assume(nameA != nameB); + vm.assume(accountA != address(0)); + vm.assume(accountB != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameA, accountA); + + // Binding `nameA` did not bind `nameB`. + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, nameB)); + sRegistry.get(nameB); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameB, accountB); + + assertEq(sRegistry.get(nameA), accountA); + assertEq(sRegistry.get(nameB), accountB); + } + + /// `Register` is emitted with the name and account both indexed, so the log + /// can be filtered by either. The log is the only enumeration of the + /// registry, so a binding that does not emit is a binding nobody can find. + function testRegisterEvent(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.recordLogs(); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + assertEq(entries.length, 1); + assertEq(entries[0].emitter, address(sRegistry)); + assertEq(entries[0].topics.length, 3); + assertEq(entries[0].topics[0], keccak256("Register(bytes32,address)")); + assertEq(entries[0].topics[1], name); + assertEq(entries[0].topics[2], bytes32(uint256(uint160(account)))); + assertEq(entries[0].data.length, 0); + } + + /// A re-binding emits its own `Register`, so the log is the full history and + /// the most recent entry for a name is its current binding. Without this an + /// indexer would still be serving the original binding after a rotation. + function testRegisterRebindEvent(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.recordLogs(); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + assertEq(entries.length, 1); + assertEq(entries[0].topics[1], name); + assertEq(entries[0].topics[2], bytes32(uint256(uint160(account)))); + } + + /// A rejected `register` emits nothing, so a failed bind can never be + /// mistaken for a binding by anything reading the logs. + function testRegisterNoEventOnRevert(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.recordLogs(); + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + assertEq(vm.getRecordedLogs().length, 0); + } +} diff --git a/test/src/lib/LibAddressRegistry.t.sol b/test/src/lib/LibAddressRegistry.t.sol index 160e6e9..6044495 100644 --- a/test/src/lib/LibAddressRegistry.t.sol +++ b/test/src/lib/LibAddressRegistry.t.sol @@ -4,25 +4,26 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibAddressRegistry} from "../../../src/lib/LibAddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; -import {ADDRESS_REGISTRY_CREATION_CODE, ADDRESS_REGISTRY_ROOT} from "../../lib/AddressRegistryPins.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; /// @title LibAddressRegistryTest /// Tests for `LibAddressRegistry`. The registry is not mocked: the real -/// `AddressRegistry` creation code is deployed through the Zoltu factory, which -/// is what puts it at the pinned address with the pinned code hash, so every -/// test here runs against the same bytecode a network would. +/// `AddressRegistry` is deployed through the Zoltu factory, which is what puts +/// it at the pinned address with the pinned code hash, so every test runs +/// against the same bytecode a network would. /// /// External wrappers are used for the library function so `vm.expectRevert` /// lands at the correct call depth. contract LibAddressRegistryTest is Test { - /// Deploys the pinned `AddressRegistry` creation code through the Zoltu - /// factory, which lands it at `LibAddressRegistry.ADDRESS_REGISTRY`. + /// Deploys `AddressRegistry` through the Zoltu factory, which lands it at + /// the pinned address. /// @return The deployed registry. function deployRegistry() internal returns (IAddressRegistryV1) { LibRainDeploy.etchZoltuFactory(vm); - return IAddressRegistryV1(LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE)); + return IAddressRegistryV1(LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode)); } /// External wrapper for `resolve` so that `vm.expectRevert` works at the @@ -33,19 +34,6 @@ contract LibAddressRegistryTest is Test { return LibAddressRegistry.resolve(name); } - /// The pins are derived from the registry's creation code, not asserted: - /// deploying that creation code through the Zoltu factory MUST land at - /// `ADDRESS_REGISTRY` with `ADDRESS_REGISTRY_CODEHASH`. The address is also - /// derivable without deploying at all, and both derivations MUST agree. - function testAddressRegistryPinsAreDerivable() external { - assertEq(LibRainDeploy.zoltuAddress(ADDRESS_REGISTRY_CREATION_CODE), LibAddressRegistry.ADDRESS_REGISTRY); - - address deployed = address(deployRegistry()); - assertEq(deployed, LibAddressRegistry.ADDRESS_REGISTRY); - assertEq(deployed.codehash, LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); - assertEq(keccak256(deployed.code), LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); - } - /// A bound name resolves to the address it is bound to. function testResolveRegistered(bytes32 name, address account) external { vm.assume(account != address(0)); @@ -57,6 +45,24 @@ contract LibAddressRegistryTest is Test { assertEq(LibAddressRegistry.resolve(name), account); } + /// `resolve` answers with the current binding, not the first one. A caller + /// that wants an answer which cannot move has to read once and store it — + /// the library deliberately does not pretend to offer that itself. + function testResolveFollowsRebinding(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + vm.assume(bound != account); + IAddressRegistryV1 registry = deployRegistry(); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, bound); + assertEq(LibAddressRegistry.resolve(name), bound); + + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, account); + assertEq(LibAddressRegistry.resolve(name), account); + } + /// An unbound name reverts. The registry, not this library, is what refuses /// to answer with the zero address, so the revert arrives unmodified. function testResolveUnregistered(bytes32 name) external { @@ -70,12 +76,12 @@ contract LibAddressRegistryTest is Test { /// calling into an empty account, which would otherwise succeed silently /// and return nothing. function testResolveNoRegistry(bytes32 name) external { - assertEq(LibAddressRegistry.ADDRESS_REGISTRY.code.length, 0); + assertEq(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); vm.expectRevert( abi.encodeWithSelector( LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, - LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH, + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, bytes32(0) ) ); @@ -87,13 +93,13 @@ contract LibAddressRegistryTest is Test { /// the caller did not compile against. function testResolveWrongCode(bytes32 name, bytes memory code) external { vm.assume(code.length > 0); - vm.assume(keccak256(code) != LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); - vm.etch(LibAddressRegistry.ADDRESS_REGISTRY, code); + vm.assume(keccak256(code) != LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); + vm.etch(LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, code); vm.expectRevert( abi.encodeWithSelector( LibAddressRegistry.UnexpectedAddressRegistryCodeHash.selector, - LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH, + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, keccak256(code) ) ); diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index 3fa79fa..a4c08fd 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -4,12 +4,12 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {LibAddressRegistry} from "../../../src/lib/LibAddressRegistry.sol"; import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; +import {MockResolvedOwner} from "../../concrete/MockResolvedOwner.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import {MockReverter} from "../../concrete/MockReverter.sol"; -import {ADDRESS_REGISTRY_CREATION_CODE, ADDRESS_REGISTRY_ROOT} from "../../lib/AddressRegistryPins.sol"; /// @title LibRainDeployTest /// Tests for `LibRainDeploy`. External wrappers are used for library functions @@ -631,221 +631,287 @@ contract LibRainDeployTest is Test { ); } - /// Deploys the real `AddressRegistry` creation code through the Zoltu - /// factory, which lands it at `LibAddressRegistry.ADDRESS_REGISTRY`, and - /// binds `name` to `account` as root. - /// @param name The name to bind. + /// Deploys `AddressRegistry` through the Zoltu factory (which lands it at + /// its pinned address), binds `name` to `account` as root, then deploys a + /// consumer that resolves `name` once in its constructor. + /// @param name The name to bind and resolve. /// @param account The address to bind it to. - function deployRegistryWithBinding(bytes32 name, address account) internal { + /// @return registry The deployed registry. + /// @return consumer The deployed consumer holding the resolved address. + function deployRegistryAndConsumer(bytes32 name, address account) + internal + returns (IAddressRegistryV1 registry, MockResolvedOwner consumer) + { LibRainDeploy.etchZoltuFactory(vm); - IAddressRegistryV1 registry = IAddressRegistryV1(LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE)); + registry = IAddressRegistryV1(LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode)); vm.prank(ADDRESS_REGISTRY_ROOT); registry.register(name, account); + consumer = new MockResolvedOwner(name); } - /// External wrapper for `checkRegisteredAddresses` so that - /// `vm.expectRevert` works at the correct call depth. + /// The calldata for reading `MockResolvedOwner`'s stored address. + /// @return The single-element read call list. + function ownerReadCalls() internal pure returns (bytes[] memory) { + bytes[] memory readCalls = new bytes[](1); + readCalls[0] = abi.encodeWithSignature("iOwner()"); + return readCalls; + } + + /// A single-element expected address list. + /// @param account The expected address. + /// @return The list. + function expected(address account) internal pure returns (address[] memory) { + address[] memory expectedAddresses = new address[](1); + expectedAddresses[0] = account; + return expectedAddresses; + } + + /// External wrapper for `checkResolvedAddresses` so that `vm.expectRevert` + /// works at the correct call depth. /// @param network The network name, for the error only. - /// @param names The names to resolve. - /// @param expectedAddresses The address each name MUST resolve to. - function externalCheckRegisteredAddresses( + /// @param target The deployed contract to read. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with. + function externalCheckResolvedAddresses( string memory network, - bytes32[] memory names, + address target, + bytes[] memory readCalls, address[] memory expectedAddresses ) external view { - LibRainDeploy.checkRegisteredAddresses(network, names, expectedAddresses); + LibRainDeploy.checkResolvedAddresses(network, target, readCalls, expectedAddresses); } - /// External wrapper for `checkRegisteredAddressesOnNetworks` so that + /// External wrapper for `checkResolvedAddressesOnNetworks` so that /// `vm.expectRevert` works at the correct call depth. /// @param networks The list of network names to check. - /// @param names The names to resolve on each network. - /// @param expectedAddresses The address each name MUST resolve to. - function externalCheckRegisteredAddressesOnNetworks( + /// @param target The deployed contract to read on each network. + /// @param readCalls The calldata for each read. + /// @param expectedAddresses The address each read MUST answer with. + function externalCheckResolvedAddressesOnNetworks( string[] memory networks, - bytes32[] memory names, + address target, + bytes[] memory readCalls, address[] memory expectedAddresses ) external { - LibRainDeploy.checkRegisteredAddressesOnNetworks(vm, networks, names, expectedAddresses); + LibRainDeploy.checkResolvedAddressesOnNetworks(vm, networks, target, readCalls, expectedAddresses); } - /// `checkRegisteredAddresses` MUST pass when every name resolves to the - /// address paired with it. - function testCheckRegisteredAddressesMatch(bytes32 name, address account) external { + /// `checkResolvedAddresses` MUST pass when the deployed contract holds the + /// address the deployment expects. + function testCheckResolvedAddressesMatch(bytes32 name, address account) external { vm.assume(account != address(0)); - deployRegistryWithBinding(name, account); - - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = account; + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); - LibRainDeploy.checkRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + LibRainDeploy.checkResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(account)); } - /// `checkRegisteredAddresses` MUST revert with `UnexpectedRegisteredAddress` - /// when a name resolves to an address other than the expected one, naming - /// the network so the failure identifies where it disagrees. - function testCheckRegisteredAddressesMismatchReverts(bytes32 name, address account, address expected) external { + /// The check is against settled state, which is the entire point of running + /// it after the deploy rather than before. Re-binding the name afterwards + /// changes what the registry answers but cannot change what the deployed + /// contract holds, so the check still passes against the address the + /// deployment actually took. + function testCheckResolvedAddressesUnaffectedByRebinding(bytes32 name, address account, address rebound) external { vm.assume(account != address(0)); - vm.assume(expected != account); - deployRegistryWithBinding(name, account); + vm.assume(rebound != address(0)); + vm.assume(rebound != account); + (IAddressRegistryV1 registry, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = expected; + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(name, rebound); + assertEq(registry.get(name), rebound); + assertEq(consumer.iOwner(), account); + LibRainDeploy.checkResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(account)); + + // And the value the registry now answers with is NOT what this + // deployment holds, so a check against it fails. vm.expectRevert( abi.encodeWithSelector( - LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, name, expected, account + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(0), + rebound, + account ) ); - this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + this.externalCheckResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(rebound)); } - /// `checkRegisteredAddresses` MUST check every name, not only the first, so - /// a later name that disagrees still stops the deployment. - function testCheckRegisteredAddressesChecksEveryName( - bytes32 nameA, - bytes32 nameB, - address account, - address expected - ) external { - vm.assume(nameA != nameB); + /// `checkResolvedAddresses` MUST revert with `UnexpectedResolvedAddress` + /// when the deployed contract holds something else, naming the network and + /// which read disagreed. + function testCheckResolvedAddressesMismatchReverts(bytes32 name, address account, address wrong) external { vm.assume(account != address(0)); - vm.assume(expected != account); - deployRegistryWithBinding(nameA, account); - vm.prank(ADDRESS_REGISTRY_ROOT); - IAddressRegistryV1(LibAddressRegistry.ADDRESS_REGISTRY).register(nameB, account); + vm.assume(wrong != account); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(0), + wrong, + account + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), ownerReadCalls(), expected(wrong)); + } + + /// `checkResolvedAddresses` MUST check every read, not only the first, so a + /// later value that disagrees is still caught. + function testCheckResolvedAddressesChecksEveryRead(bytes32 name, address account, address wrong) external { + vm.assume(account != address(0)); + vm.assume(wrong != account); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); - bytes32[] memory names = new bytes32[](2); - names[0] = nameA; - names[1] = nameB; + bytes[] memory readCalls = new bytes[](2); + readCalls[0] = abi.encodeWithSignature("iOwner()"); + readCalls[1] = abi.encodeWithSignature("iOwner()"); address[] memory expectedAddresses = new address[](2); expectedAddresses[0] = account; - expectedAddresses[1] = expected; + expectedAddresses[1] = wrong; vm.expectRevert( abi.encodeWithSelector( - LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, nameB, expected, account + LibRainDeploy.UnexpectedResolvedAddress.selector, + "test_network", + address(consumer), + uint256(1), + wrong, + account ) ); - this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + this.externalCheckResolvedAddresses("test_network", address(consumer), readCalls, expectedAddresses); } - /// `checkRegisteredAddresses` MUST propagate the registry's own revert for - /// an unbound name, so a network where a name was never bound fails as - /// loudly as one where it disagrees. - function testCheckRegisteredAddressesUnregisteredReverts(bytes32 name, address expected) external { - LibRainDeploy.etchZoltuFactory(vm); - LibRainDeploy.deployZoltu(ADDRESS_REGISTRY_CREATION_CODE); + /// A read that cannot be answered is never a pass. An address with no code + /// static-calls successfully and returns nothing, which would compare equal + /// to nothing at all if the length were not checked. + function testCheckResolvedAddressesUnreadableTargetReverts(address target, address account) external { + vm.assume(target.code.length == 0); - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = expected; + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, "test_network", target, uint256(0), bytes("") + ) + ); + this.externalCheckResolvedAddresses("test_network", target, ownerReadCalls(), expected(account)); + } + + /// A read that reverts is never a pass either. + function testCheckResolvedAddressesRevertingReadReverts(bytes32 name, address account) external { + vm.assume(account != address(0)); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + + bytes[] memory readCalls = new bytes[](1); + readCalls[0] = abi.encodeWithSignature("thisFunctionDoesNotExist()"); - vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); - this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, + "test_network", + address(consumer), + uint256(0), + bytes("") + ) + ); + this.externalCheckResolvedAddresses("test_network", address(consumer), readCalls, expected(account)); } - /// `checkRegisteredAddresses` MUST revert when the names and expected + /// `checkResolvedAddresses` MUST revert when the reads and expected /// addresses do not pair up, rather than checking the shorter of the two. - function testCheckRegisteredAddressesLengthMismatchReverts(uint8 namesLength, uint8 expectedLength) external { - vm.assume(namesLength != expectedLength); + function testCheckResolvedAddressesLengthMismatchReverts(uint8 readCallsLength, uint8 expectedLength) external { + vm.assume(readCallsLength != expectedLength); - bytes32[] memory names = new bytes32[](namesLength); + bytes[] memory readCalls = new bytes[](readCallsLength); address[] memory expectedAddresses = new address[](expectedLength); vm.expectRevert( abi.encodeWithSelector( - LibRainDeploy.RegisteredAddressesLengthMismatch.selector, uint256(namesLength), uint256(expectedLength) + LibRainDeploy.ResolvedAddressesLengthMismatch.selector, + uint256(readCallsLength), + uint256(expectedLength) ) ); - this.externalCheckRegisteredAddresses(LibRainDeploy.BASE, names, expectedAddresses); + this.externalCheckResolvedAddresses("test_network", address(this), readCalls, expectedAddresses); } - /// `checkRegisteredAddressesOnNetworks` MUST revert with `NoNetworks` when - /// given none, so an empty target set can never be mistaken for every name + /// `checkResolvedAddressesOnNetworks` MUST revert with `NoNetworks` when + /// given none, so an empty target set can never be mistaken for every read /// checking out. - function testCheckRegisteredAddressesOnNetworksNoNetworksReverts(bytes32 name, address expected) external { + function testCheckResolvedAddressesOnNetworksNoNetworksReverts(address account) external { string[] memory networks = new string[](0); - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = expected; vm.expectRevert(abi.encodeWithSelector(LibRainDeploy.NoNetworks.selector)); - this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + this.externalCheckResolvedAddressesOnNetworks(networks, address(this), ownerReadCalls(), expected(account)); } - /// `checkRegisteredAddressesOnNetworks` MUST check the names and expected + /// `checkResolvedAddressesOnNetworks` MUST check the reads and expected /// addresses pair up before it forks anything, so a mispaired call is /// reported without any network being reachable at all. - function testCheckRegisteredAddressesOnNetworksLengthMismatchRevertsBeforeForking() external { + function testCheckResolvedAddressesOnNetworksLengthMismatchRevertsBeforeForking() external { string[] memory networks = new string[](1); // Not a configured RPC alias, so forking it is itself an error. networks[0] = "unconfigured_network"; - bytes32[] memory names = new bytes32[](2); + bytes[] memory readCalls = new bytes[](2); address[] memory expectedAddresses = new address[](1); vm.expectRevert( - abi.encodeWithSelector(LibRainDeploy.RegisteredAddressesLengthMismatch.selector, uint256(2), uint256(1)) + abi.encodeWithSelector(LibRainDeploy.ResolvedAddressesLengthMismatch.selector, uint256(2), uint256(1)) ); - this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + this.externalCheckResolvedAddressesOnNetworks(networks, address(this), readCalls, expectedAddresses); } - /// `checkRegisteredAddressesOnNetworks` MUST fork each network in turn and - /// pass when the name resolves to the expected address on all of them. The - /// registry is made persistent so the same binding is present on every - /// fork, which is the state the check exists to confirm. Fixed inputs - /// rather than fuzzed: what varies here is the network, and every run forks - /// each one. + /// `checkResolvedAddressesOnNetworks` MUST fork each network in turn and + /// pass when the deployed contract holds the expected address on all of + /// them. The deployment is made persistent so the same contract is present + /// on every fork, which is the state a real multi-network deploy leaves + /// behind. /// /// Two networks rather than `supportedNetworks()`. What is under test is /// that the loop visits every network it is given, which two prove as well /// as five; the roster itself is `testSupportedNetworks`'s job. These are /// the two networks the rest of this suite forks, so the test does not /// depend on the reliability of RPC endpoints nothing else here touches. - function testCheckRegisteredAddressesOnNetworksEachNetwork() external { - bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksEachNetwork"); + function testCheckResolvedAddressesOnNetworksEachNetwork() external { + bytes32 name = keccak256("testCheckResolvedAddressesOnNetworksEachNetwork"); address account = address(0xf00); - deployRegistryWithBinding(name, account); - vm.makePersistent(LibAddressRegistry.ADDRESS_REGISTRY); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + vm.makePersistent(address(consumer)); string[] memory networks = new string[](2); networks[0] = LibRainDeploy.ARBITRUM_ONE; networks[1] = LibRainDeploy.BASE; - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = account; - LibRainDeploy.checkRegisteredAddressesOnNetworks(vm, networks, names, expectedAddresses); + LibRainDeploy.checkResolvedAddressesOnNetworks( + vm, networks, address(consumer), ownerReadCalls(), expected(account) + ); } - /// `checkRegisteredAddressesOnNetworks` MUST fail on the network that + /// `checkResolvedAddressesOnNetworks` MUST fail on the network that /// disagrees, and MUST name it. - function testCheckRegisteredAddressesOnNetworksMismatchReverts() external { - bytes32 name = keccak256("testCheckRegisteredAddressesOnNetworksMismatchReverts"); + function testCheckResolvedAddressesOnNetworksMismatchReverts() external { + bytes32 name = keccak256("testCheckResolvedAddressesOnNetworksMismatchReverts"); address account = address(0xf00); - address expected = address(0xba4); - deployRegistryWithBinding(name, account); - vm.makePersistent(LibAddressRegistry.ADDRESS_REGISTRY); + address wrong = address(0xba4); + (, MockResolvedOwner consumer) = deployRegistryAndConsumer(name, account); + vm.makePersistent(address(consumer)); string[] memory networks = new string[](1); networks[0] = LibRainDeploy.BASE; - bytes32[] memory names = new bytes32[](1); - names[0] = name; - address[] memory expectedAddresses = new address[](1); - expectedAddresses[0] = expected; vm.expectRevert( abi.encodeWithSelector( - LibRainDeploy.UnexpectedRegisteredAddress.selector, LibRainDeploy.BASE, name, expected, account + LibRainDeploy.UnexpectedResolvedAddress.selector, + LibRainDeploy.BASE, + address(consumer), + uint256(0), + wrong, + account ) ); - this.externalCheckRegisteredAddressesOnNetworks(networks, names, expectedAddresses); + this.externalCheckResolvedAddressesOnNetworks(networks, address(consumer), ownerReadCalls(), expected(wrong)); } } From d58d007996463c392846d854044a338edea0b7d5 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:48:45 +0000 Subject: [PATCH 05/29] test(deploy): repin testDeployZoltu's literal to this repo's compiler settings The literal is the address the live factory returns for MockDeployable's creation code, which is a function of the settings that compile it. Pinning solc/optimizer/evm_version in foundry.toml - needed because this repo's deploy pins depend on them - moved it. The comment now says which settings it is a function of, since that is what makes a literal here stable at all. --- test/src/lib/LibRainDeploy.t.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index a4c08fd..f33f5fa 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -308,9 +308,13 @@ contract LibRainDeployTest is Test { // Pinned literal, deliberately not `mockDeployableAddress()`. The live // factory on the fork is the oracle here, so an expected value taken // from the derivation would only check `zoltuAddress` against itself. - // It is the address the factory returns for the creation code solc - // 0.8.25 emits for `MockDeployable`, which itself pins `=0.8.25`. - assertEq(deployed, 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854); + // It is the address the factory returns for the creation code this + // repo's compiler settings emit for `MockDeployable` — solc 0.8.25, + // optimizer on at 100,000 runs, targeting cancun. Those settings are + // now pinned exactly in `foundry.toml`, because this repo's deploy pins + // depend on them; that is what makes a literal here stable at all, and + // moving any of them moves this address. + assertEq(deployed, 0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); } /// `deployZoltu` MUST revert with `DeployFailed` when the Zoltu factory From 10b14d5b1b56c1674ee72c755877aa764f8bfaec Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 13:04:36 +0000 Subject: [PATCH 06/29] feat(verify): one inherited deploy-pin verification, parameterized over versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy-pin verification was hand-written per repo, enumerated per version and per chain, and did not check the thing that matters. `src/abstract/ RainDeployVerify*.sol` is that verification, inherited instead. The creation code is the only parameter. Zoltu is CREATE2 over its calldata under a zero salt, so the address is a pure function of it, and running it once locally gives the runtime code and its hash. The address, code hash and runtime code a pointers file records become checked outputs. Three groups, sorted by what each is anchored to: - internal to the recorded set — catches an inconsistently generated set, and provably CANNOT catch a snapshot of the wrong contract - anchored to source — the only check that catches a wrong-contract snapshot, candidate only because a released tag is meant to diverge from source - anchored to chain — the only check that catches never-deployed or not-there-any-more, across every network in supportedNetworks() The chain group is its own contract so an unreachable RPC endpoint fails only it: `forge test --no-match-contract Chain` is the whole offline gate, and nothing reachable from the offline contracts forks anything. In src/, not test/: .soldeerignore excludes /test from the published package, so a consumer could not import it from there. A per-chain code hash difference is a DEFECT, not a shape to record — it fails hard naming the chain and both hashes. AddressRegistryDeployPins collapses onto it. Its chain contract fails, on every network, because AddressRegistry has never been deployed. That is the check working: no offline assertion can discover it, and a green there would only mean nobody asked. Also renames script/BuildPointers.sol to script/Build.sol, the convention eight org repos already use, per rainlanguage/rainix#304. Closes #27 --- .github/workflows/package-release.yaml | 2 +- CLAUDE.md | 58 ++++- README.md | 68 +++++ foundry.toml | 2 +- script/{BuildPointers.sol => Build.sol} | 8 +- src/abstract/RainDeployVerifyBase.sol | 199 +++++++++++++++ src/abstract/RainDeployVerifyChain.sol | 104 ++++++++ src/abstract/RainDeployVerifyOffline.sol | 112 +++++++++ src/lib/LibAddressRegistryDeploy.sol | 6 +- test/abstract/MockDeployVersions.sol | 72 ++++++ .../0_0_1/MockDeployable.pointers.sol | 34 +++ .../0_0_2/MockDeployableV2.pointers.sol | 31 +++ test/src/abstract/RainDeployVerifyChain.t.sol | 214 ++++++++++++++++ .../abstract/RainDeployVerifyOffline.t.sol | 234 ++++++++++++++++++ .../concrete/AddressRegistryDeployPins.t.sol | 116 ++++++--- 15 files changed, 1210 insertions(+), 50 deletions(-) rename script/{BuildPointers.sol => Build.sol} (97%) create mode 100644 src/abstract/RainDeployVerifyBase.sol create mode 100644 src/abstract/RainDeployVerifyChain.sol create mode 100644 src/abstract/RainDeployVerifyOffline.sol create mode 100644 test/abstract/MockDeployVersions.sol create mode 100644 test/fixtures/0_0_1/MockDeployable.pointers.sol create mode 100644 test/fixtures/0_0_2/MockDeployableV2.pointers.sol create mode 100644 test/src/abstract/RainDeployVerifyChain.t.sol create mode 100644 test/src/abstract/RainDeployVerifyOffline.t.sol diff --git a/.github/workflows/package-release.yaml b/.github/workflows/package-release.yaml index 4b2999f..f1c9fe1 100644 --- a/.github/workflows/package-release.yaml +++ b/.github/workflows/package-release.yaml @@ -23,5 +23,5 @@ jobs: uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main with: soldeer-package: rain-deploy - snapshot-generate-cmd: forge script ./script/BuildPointers.sol && forge fmt + snapshot-generate-cmd: forge script ./script/Build.sol && forge fmt secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index d98c437..1efbc9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,10 +50,17 @@ Fork tests require RPC endpoints defined in `.env` (gitignored): ```bash ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc BASE_RPC_URL=https://mainnet.base.org +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc -POLYGON_RPC_URL=https://polygon-rpc.com +POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com ``` +All five are needed: `RainDeployVerifyChain` forks every network in +`supportedNetworks()`, so a missing or rate-limited endpoint fails it. Those +failures are `vm.createSelectFork` errors, distinct from the +`NotDeployedOnNetwork` a reachable network raises, and the offline contracts run +regardless: `forge test --no-match-contract Chain`. + These are referenced in `foundry.toml` under `[rpc_endpoints]`. ## Architecture @@ -98,15 +105,58 @@ code hash. **`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the creation code this repo compiles under this repo's own settings and checked -against it by `AddressRegistryDeployPinsTest`. Hand-written until the first -`sol-v*` release generates it from `src/generated//`; no snapshot is frozen -while the root is a placeholder, because that directory is append-only. +against it by `AddressRegistryDeployPinsOfflineTest`. Hand-written until the +first `sol-v*` release generates it from `src/generated//`; no snapshot is +frozen while the root is a placeholder, because that directory is append-only. **`src/lib/LibAddressRegistry.sol`** — reads that registry at its deterministic address, verifying its code hash first, exactly as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH`. It resolves a name to an address and nothing more: what a consumer resolves a name for, and when, is the consumer's business. +**`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every +deploy repo inherits instead of hand-writing. In `src/`, not `test/`, because +`.soldeerignore` excludes `/test` from the published package and a consumer that +cannot import it cannot use it. + +A repo declares its versions once — `releasedVersions()` and +`candidateVersion()` on one abstract contract — and inherits that into one +`RainDeployVerifyOffline` and one `RainDeployVerifyChain`. Nothing is per +version and nothing is per network. + +The creation code is the only parameter. The Zoltu factory is `CREATE2` over its +calldata under a zero salt, so the address is a pure function of it, and running +it once locally gives the runtime code and its hash. The address, code hash and +runtime code a pointers file records are checked OUTPUTS. + +Three groups, sorted by what they are anchored to: + +1. **Internal to the recorded set** (`RainDeployVerifyOffline`) — what a version + records is what its own creation code derives. Catches a set generated + inconsistently. CANNOT catch a snapshot of the wrong contract: a consistent + snapshot of the wrong thing satisfies all of it, which + `testWrongContractSnapshotPassesInternalConsistency` pins. +2. **Anchored to source** (`RainDeployVerifyOffline`) — the candidate's recorded + creation code is `type(X).creationCode`. The only check that catches a + wrong-contract snapshot. Candidate only, because a released tag is MEANT to + diverge from current source; there is no field on a released version to spell + it, so it cannot be opted into or out of. +3. **Anchored to chain** (`RainDeployVerifyChain`) — across + `supportedNetworks()`, every version's derived address carries code with its + derived code hash. The only check that catches "never deployed" or "not there + any more", neither of which the repo can hold: both go false with nobody + touching it. + +Group 3 lives in its own contract so an unreachable RPC endpoint fails only it, +never the assertions that hold offline — `forge test --no-match-contract Chain` +is the whole offline gate, and nothing reachable from those contracts forks +anything. + +A single recorded code hash per version can only be true if the runtime code is +the same on every network, so a constructor reading `block.chainid` or similar +is a DEFECT: it fails hard, naming the chain and both hashes. There is +deliberately no per-chain code hash to record. + The libraries are designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, expected addresses, expected code hashes, and dependency lists. diff --git a/README.md b/README.md index b94fa54..ec7c8a3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ It answers: - How does a deployment get a configured address — an owner, say — without baking one into its creation code, where changing it would move every future deployment? +- Is every version I have ever released still live, with the code I compiled, on + every network I support? Approach: @@ -31,6 +33,72 @@ Approach: - An address registry, read at run time rather than compiled into creation code, and a post-deploy check that every target network's deployment took the address it was supposed to. +- One inherited deploy-pin verification, parameterized over versions, rather + than assertions hand-enumerated per version and per chain in every deploy + repo. + +## Deploy verification + +A deploy repo records, per released version, a deterministic address, a code +hash and the bytecode behind them. `src/abstract/RainDeployVerify*.sol` is the +verification of those records, inherited rather than rewritten. + +**The creation code is the only parameter.** The Zoltu factory is `CREATE2` over +its calldata under a zero salt, so the address is a pure function of the +creation code and identical on every network, and running that creation code +once locally yields the runtime code and its hash. Everything else a pointers +file holds is a checked output. + +A repo declares its versions once and inherits that declaration into one offline +contract and one chain contract: + +```solidity +abstract contract MyDeployVersions is RainDeployVerifyBase { + function releasedVersions() internal pure override returns (DeployVersion[] memory) { /* frozen snapshots */ } + function candidateVersion() internal pure override returns (DeployCandidate memory) { /* current source */ } +} + +contract MyDeployPinsOfflineTest is MyDeployVersions, RainDeployVerifyOffline {} +contract MyDeployPinsChainTest is MyDeployVersions, RainDeployVerifyChain {} +``` + +There is nothing per version and nothing per network. A new release adds an +array entry; a network added to `supportedNetworks()` is checked for every +version already recorded, which is exactly the cell a hand-written suite never +grows. + +Three groups, sorted by what each is anchored to and therefore by what each can +catch: + +| Group | Anchored to | Catches | Cannot catch | +| -------- | ---------------------- | ------------------------------------- | -------------------------------- | +| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract | +| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain | +| Chain | the networks | never deployed, or not there any more | anything before it is deployed | + +The internal group's blind spot is not a gap to close there: every check in it +asks the recorded bytes to agree with each other, and the wrong contract's bytes +agree with each other perfectly. The source anchor is the only thing that +catches it, and it applies to the **candidate only** — a released tag is meant +to have diverged from current source, so anchoring one to source asserts +something false by design. That is a property of the assertion, and there is no +field on a released version with which to opt in or out. + +The chain group has no such exemption. It applies to every version, including +one that has never been deployed — where it fails, and that failure is the +answer. + +It is a separate contract so that an unreachable RPC endpoint fails only it. +`forge test --no-match-contract Chain` is the whole offline gate, and it is +structural rather than conventional: nothing reachable from the offline +contracts forks anything. + +**Chain-independent runtime code is a requirement, not a caveat.** One recorded +code hash per version can only be true if the runtime code is the same +everywhere. A constructor that reads `block.chainid` deploys different code per +chain: deploying through Zoltu buys address predictability, and such a +constructor spends it. So a per-chain difference fails hard, naming the chain +and both hashes, and there is deliberately no per-chain code hash to record. ## Address registry diff --git a/foundry.toml b/foundry.toml index 3375b40..3d37531 100644 --- a/foundry.toml +++ b/foundry.toml @@ -25,7 +25,7 @@ evm_version = "cancun" cbor_metadata = false bytecode_hash = "none" -# BuildPointers reads the version from foundry.toml and writes the generated +# Build reads the version from foundry.toml and writes the generated # per-tag snapshots + the current-pin lib under src/. Nothing else in this repo # touches the filesystem. fs_permissions = [ diff --git a/script/BuildPointers.sol b/script/Build.sol similarity index 97% rename from script/BuildPointers.sol rename to script/Build.sol index cd69bea..4b22e6c 100644 --- a/script/BuildPointers.sol +++ b/script/Build.sol @@ -8,7 +8,7 @@ import {LibFs} from "rain-sol-codegen-0.1.0/src/lib/LibFs.sol"; import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; -/// @title BuildPointers +/// @title Build /// @notice Generates the deterministic-deploy pins for `AddressRegistry`: /// 1. A frozen per-release snapshot /// `src/generated//AddressRegistry.pointers.sol` (`BYTECODE_HASH`, @@ -20,10 +20,10 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// snapshot stays the single source of truth (never a duplicated literal). /// Kept in `src/lib` so consumers' import path is stable across releases. /// -/// Run as `forge script script/BuildPointers.sol`. Wired into +/// Run as `forge script script/Build.sol`. Wired into /// `rainix-tag-release`'s `snapshot-generate-cmd`, so a release regenerates the /// pins for the version the tag names. -contract BuildPointers is Script { +contract Build is Script { string constant GEN_LIB_PATH = "src/lib/LibAddressRegistryDeploy.sol"; // REUSE-IgnoreStart (the two SPDX lines below are the header EMITTED into the @@ -104,7 +104,7 @@ contract BuildPointers is Script { vm.writeLine(GEN_LIB_PATH, GEN_SPDX_COPYRIGHT); vm.writeLine(GEN_LIB_PATH, "pragma solidity ^0.8.25;"); vm.writeLine(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY ./script/BuildPointers.sol"); + vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY ./script/Build.sol"); vm.writeLine(GEN_LIB_PATH, ""); vm.writeLine(GEN_LIB_PATH, "import {"); vm.writeLine(GEN_LIB_PATH, " DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR,"); diff --git a/src/abstract/RainDeployVerifyBase.sol b/src/abstract/RainDeployVerifyBase.sol new file mode 100644 index 0000000..5865571 --- /dev/null +++ b/src/abstract/RainDeployVerifyBase.sol @@ -0,0 +1,199 @@ +// 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 "../lib/LibRainDeploy.sol"; + +/// Thrown when the pure `LibRainDeploy.zoltuAddress` formula and an actual +/// deploy through the etched Zoltu factory bytecode disagree about where a +/// creation code lands. Both are `LibRainDeploy`'s, so this is a defect in the +/// library rather than in any snapshot, and it invalidates every derivation +/// made from it. +/// @param version The version label whose creation code was being derived. +/// @param formulaAddress The address `zoltuAddress` computed. +/// @param factoryAddress The address the factory bytecode actually deployed to. +error ZoltuDerivationMismatch(string version, address formulaAddress, address factoryAddress); + +/// One recorded deployment of one version of one contract. +/// +/// `creationCode` is the ONLY input. Everything else is an OUTPUT that gets +/// checked against it: the Zoltu factory is `CREATE2` over its calldata under a +/// zero salt, so the deploy address is a pure function of the creation code and +/// identical on every network, and running that creation code once locally +/// yields the runtime code and its hash. A pointers file records all four, but +/// only one of them is a parameter. +/// +/// `creationCode` comes from wherever this version's creation code is recorded: +/// the frozen `CREATION_CODE` constant of a released snapshot, or +/// `type(X).creationCode` for a version that has no frozen snapshot yet. +struct DeployVersion { + /// The version label, e.g. `0_1_5` or `candidate`. Carried into every error + /// so a failure names the version that failed rather than an array index. + string version; + /// The creation code this version is a snapshot of. The only parameter. + bytes creationCode; + /// The deploy address recorded for this version, to be checked against the + /// address `creationCode` derives. + address storedDeployedAddress; + /// The deployed code hash recorded for this version, to be checked against + /// the hash `creationCode` produces. + bytes32 storedBytecodeHash; + /// The runtime code recorded for this version, to be checked against + /// `storedBytecodeHash`. A frozen `RUNTIME_CODE` constant for a released + /// snapshot, or `type(X).runtimeCode` where nothing is frozen yet. + bytes storedRuntimeCode; +} + +/// The rolling candidate: the snapshot that tracks current source rather than a +/// frozen release, paired with the current source's creation code it MUST +/// equal. +/// +/// This pairing is the ONLY thing that catches a snapshot of the wrong +/// contract. Every check internal to a snapshot is satisfied by a consistent +/// snapshot of the wrong thing, so without an anchor to source there is nothing +/// that says the recorded bytes belong to the contract this repo compiles. +/// +/// It is deliberately absent from `DeployVersion` and therefore from released +/// versions: a released tag is MEANT to diverge from current source, so +/// anchoring one to source would fail on every release that is not the newest. +/// That is a property of the assertion, not an opt-out — there is no way for a +/// caller to spell "released, and also skip the checks that do apply". +struct DeployCandidate { + /// The candidate's own recorded snapshot, checked exactly as any other. + DeployVersion snapshot; + /// `type(X).creationCode` for the contract the candidate claims to be. + bytes sourceCreationCode; +} + +/// What a version's creation code derives, offline and by itself. Computed +/// once and then compared against whatever claims to hold it, whether that is a +/// recorded constant or a live chain. +struct DerivedDeploy { + /// The version label the derivation came from. + string version; + /// The address the creation code deploys to, on every network. + address deployedAddress; + /// The code hash the creation code leaves behind at that address. + bytes32 bytecodeHash; +} + +/// @title RainDeployVerifyBase +/// @notice The parameterization shared by every deploy-verification group: a +/// repo declares its versions once, and the derivation from creation code to +/// (address, code hash) happens in one place rather than being restated per +/// version and per chain. +/// +/// This is not inherited directly. `RainDeployVerifyOffline` and +/// `RainDeployVerifyChain` each inherit it and contribute the checks that need +/// no network and the checks that do, respectively. A repo declares its +/// versions on one abstract contract and inherits that into one of each, so +/// running the offline checks never touches an RPC endpoint — an outage is then +/// a failure of one contract that plainly is about the chain, and can never be +/// confused with, or take down, the assertions that hold offline. +/// +/// ## Chain-independent runtime code is a requirement, not a caveat +/// +/// A single `storedBytecodeHash` per version can only be true if the runtime +/// code is the same on every network. A constructor that reads `block.chainid`, +/// or anything else that varies per chain, produces a different code hash per +/// chain and cannot be described by these snapshots at all. Deploying through +/// Zoltu buys address predictability; a constructor that reads chain state +/// spends it. So a per-chain code hash difference is a DEFECT in the contract, +/// reported as a hard failure naming the chain and both hashes, and there is +/// deliberately no per-chain code hash to record. +abstract contract RainDeployVerifyBase is Test { + /// Every FROZEN released version, in any order. A released snapshot is + /// immutable: its recorded bytes describe a deployment that already + /// happened, so it is never regenerated and never anchored to current + /// source. + /// @return The released versions to verify. + function releasedVersions() internal pure virtual returns (DeployVersion[] memory); + + /// The rolling candidate — the snapshot describing what this repo compiles + /// right now. Required rather than optional: a deploy repo always compiles + /// a current source, so there is always something for the source-anchored + /// check to anchor to, and making it optional would let the only check that + /// catches a wrong-contract snapshot be silently skipped. + /// @return The candidate to verify. + function candidateVersion() internal pure virtual returns (DeployCandidate memory); + + /// Every version this repo records: the released ones plus the candidate. + /// The checks that apply to a version regardless of its status run over + /// this. + /// @return versions The released versions followed by the candidate. + function allVersions() internal pure returns (DeployVersion[] memory versions) { + DeployVersion[] memory released = releasedVersions(); + versions = new DeployVersion[](released.length + 1); + for (uint256 i = 0; i < released.length; i++) { + versions[i] = released[i]; + } + versions[released.length] = candidateVersion().snapshot; + } + + /// Derives what a version's creation code deploys to, from the creation + /// code alone. + /// + /// The address comes from the pure `LibRainDeploy.zoltuAddress` formula and + /// the code hash from actually running the creation code through the Zoltu + /// factory bytecode locally, because the code hash cannot be known without + /// executing the constructor. The two are cross-checked against each other, + /// so a formula that drifted from the factory bytecode is caught here + /// rather than silently poisoning every downstream comparison. + /// + /// The whole derivation runs inside a state snapshot that is reverted, and + /// clears the derived address first, so that it reads ONLY what the + /// creation code produces. Both matter: + /// + /// - Two versions can legitimately share creation code (a release that + /// changed nothing that compiles), and `CREATE2` to an occupied address + /// fails. Clearing makes the second derivation work, and reverting means + /// the first never occupied it in the first place. + /// - The local deploy must not survive into the chain-anchored checks. A + /// locally deployed contract that leaked into a fork would be compared + /// against itself, and every chain would pass whether or not anything is + /// deployed there. + /// @param version The version to derive from. + /// @return derived The address and code hash the creation code produces. + function deriveDeployment(DeployVersion memory version) internal returns (DerivedDeploy memory derived) { + address formulaAddress = LibRainDeploy.zoltuAddress(version.creationCode); + + uint256 snapshotId = vm.snapshotState(); + + // Whatever is at the derived address is not part of the derivation. + // The nonce goes too: `CREATE2` collides on a non-zero nonce as well as + // on non-empty code. + vm.etch(formulaAddress, hex""); + vm.resetNonce(formulaAddress); + + LibRainDeploy.etchZoltuFactory(vm); + address factoryAddress = LibRainDeploy.deployZoltu(version.creationCode); + if (factoryAddress != formulaAddress) { + revert ZoltuDerivationMismatch(version.version, formulaAddress, factoryAddress); + } + + derived = DerivedDeploy({ + version: version.version, deployedAddress: formulaAddress, bytecodeHash: factoryAddress.codehash + }); + + // revertToState returns whether the snapshot existed; it was taken + // above, so bind and reference it to satisfy the unused-return lint + // rather than asserting on it. + bool reverted = vm.revertToState(snapshotId); + (reverted); + } + + /// Derives every version once, before anything forks. Callers that compare + /// against chains need the derivation to have already happened on a local + /// EVM, because on a fork the derived address is exactly the address the + /// deployment under test occupies. + /// @param versions The versions to derive. + /// @return derived The derivation of each, positionally paired. + function deriveDeployments(DeployVersion[] memory versions) internal returns (DerivedDeploy[] memory derived) { + derived = new DerivedDeploy[](versions.length); + for (uint256 i = 0; i < versions.length; i++) { + derived[i] = deriveDeployment(versions[i]); + } + } +} diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol new file mode 100644 index 0000000..45b55f4 --- /dev/null +++ b/src/abstract/RainDeployVerifyChain.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// Thrown when a version's derived address has no code on a network. Either it +/// never deployed there, or it is not there any more. +/// @param network The network name, as configured in `[rpc_endpoints]`. +/// @param version The version label that is missing. +/// @param deployedAddress The address that should hold it. +error NotDeployedOnNetwork(string network, string version, address deployedAddress); + +/// Thrown when a version's derived address holds code that is not the code its +/// creation code produces. +/// +/// This is also what a chain-dependent runtime code looks like: a constructor +/// that reads `block.chainid` or similar deploys different code per network, +/// so one network disagrees while others pass. That is a defect in the +/// contract, not a shortcoming of a single recorded hash — hence a hard failure +/// naming the chain and both hashes, rather than a per-chain hash to record. +/// @param network The network name, as configured in `[rpc_endpoints]`. +/// @param version The version label that failed. +/// @param deployedAddress The address checked. +/// @param expectedCodeHash The code hash the version's creation code produces. +/// @param actualCodeHash The code hash actually found on this network. +error CodeHashMismatchOnNetwork( + string network, string version, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash +); + +/// @title RainDeployVerifyChain +/// @notice The only deploy-pin assertions anchored to something outside the +/// repo: across every network in `LibRainDeploy.supportedNetworks()`, every +/// recorded version's derived address carries code with its derived code hash. +/// +/// This is the only group that can catch a version that never deployed to a +/// network, or that is not there any more. Neither is a fact the repo can hold: +/// both can go false with nobody touching it — a release that reached four +/// chains of five, a chain added to `supportedNetworks()` after a release that +/// therefore never got it, a deploy that silently failed. +/// +/// The matrix is versions by networks and is generated from both, so a new +/// network leaves no version unchecked and a new version is checked on every +/// network from the moment it is recorded. There are deliberately no per-chain +/// or per-version functions to add. +/// +/// It compares against the DERIVED code hash rather than the recorded one, so +/// the creation code stays the only parameter. `RainDeployVerifyOffline` is +/// what ties the derivation back to the recorded constants; the two together +/// say the recorded set describes what is actually live. +/// +/// Kept in its own contract, away from every assertion that holds offline, so +/// an unreachable RPC endpoint fails only this. It cannot take down the offline +/// checks with it, and its failures are legible: a fork that cannot be created +/// is an outage, while `NotDeployedOnNetwork` from a fork that was created is a +/// missing deployment. A contract boundary is what `forge test +/// --match-contract` and a CI job select at, and it is structural rather than +/// conventional — nothing reachable from the offline contract forks anything. +abstract contract RainDeployVerifyChain is RainDeployVerifyBase { + /// Checks one derived version against whichever network is currently + /// selected. + /// @param network The network name, for the error only. + /// @param derived The derivation to check for. + function checkDeployedOnNetwork(string memory network, DerivedDeploy memory derived) internal view { + if (derived.deployedAddress.code.length == 0) { + revert NotDeployedOnNetwork(network, derived.version, derived.deployedAddress); + } + bytes32 actualCodeHash = derived.deployedAddress.codehash; + if (actualCodeHash != derived.bytecodeHash) { + revert CodeHashMismatchOnNetwork( + network, derived.version, derived.deployedAddress, derived.bytecodeHash, actualCodeHash + ); + } + } + + /// Checks every derived version against every supported network, forking + /// each network once and checking every version on it. + /// + /// The derivations are taken as an argument, already computed, because they + /// have to be computed before anything forks: on a fork the derived address + /// is the very address the deployment under test occupies, so deriving + /// there would either collide with it or read it back as its own + /// expectation. + /// @param derived The derivation of every version to check. + function checkDeployedOnSupportedNetworks(DerivedDeploy[] memory derived) internal { + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + // createSelectFork returns a fork id that is not needed here; bind + // and reference it so the unused-return lint stays satisfied. + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + for (uint256 j = 0; j < derived.length; j++) { + checkDeployedOnNetwork(networks[i], derived[j]); + } + } + } + + /// Every recorded version MUST be live, with the code its creation code + /// produces, on every supported network. + function testDeployPinsLiveOnEverySupportedNetwork() external { + checkDeployedOnSupportedNetworks(deriveDeployments(allVersions())); + } +} diff --git a/src/abstract/RainDeployVerifyOffline.sol b/src/abstract/RainDeployVerifyOffline.sol new file mode 100644 index 0000000..38acf34 --- /dev/null +++ b/src/abstract/RainDeployVerifyOffline.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeployVersion, DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; + +/// Thrown when the deploy address recorded for a version is not the address its +/// own creation code derives. +/// @param version The version label that failed. +/// @param storedAddress The address the version records. +/// @param derivedAddress The address its creation code derives. +error StoredAddressMismatch(string version, address storedAddress, address derivedAddress); + +/// Thrown when the deployed code hash recorded for a version is not the hash +/// its own creation code produces. +/// @param version The version label that failed. +/// @param storedCodeHash The code hash the version records. +/// @param derivedCodeHash The code hash its creation code produces. +error StoredCodeHashMismatch(string version, bytes32 storedCodeHash, bytes32 derivedCodeHash); + +/// Thrown when the runtime code recorded for a version does not hash to the +/// code hash recorded beside it. +/// @param version The version label that failed. +/// @param storedBytecodeHash The code hash the version records. +/// @param runtimeCodeHash The hash of the runtime code the version records. +error StoredRuntimeCodeHashMismatch(string version, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); + +/// Thrown when the candidate's recorded creation code is not the creation code +/// this repo currently compiles. Hashes rather than the bytes themselves, which +/// run to tens of kilobytes. +/// @param version The candidate's version label. +/// @param storedCreationCodeHash Hash of the creation code the candidate +/// records. +/// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the +/// contract the candidate claims to be. +error CandidateSourceMismatch(string version, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); + +/// @title RainDeployVerifyOffline +/// @notice Every deploy-pin assertion that needs no network, for every version +/// a repo records. Two groups, which catch different things and are documented +/// as such because it is easy to read the first as covering the second. +/// +/// **Internal to the recorded set.** The address a version's creation code +/// derives is the address it records, the code hash that creation code produces +/// is the code hash it records, and the runtime code it records hashes to that +/// same code hash. These are real derivations and they catch a set generated +/// inconsistently — a hand-edited constant, a snapshot regenerated for one +/// field and not the others, an address copied from the wrong tag. +/// +/// They CANNOT catch a snapshot of the wrong contract. A consistent snapshot of +/// the wrong thing satisfies all three, because all three only ask the recorded +/// bytes to agree with each other, and the wrong contract's bytes agree with +/// each other perfectly. +/// +/// **Anchored to source.** The candidate's recorded creation code is the +/// creation code this repo compiles. This is the only check in the whole suite +/// that catches a snapshot of the wrong contract, and it applies to the +/// candidate alone: a released tag is meant to have diverged from current +/// source, so anchoring one to source asserts something that is false by +/// design. +/// +/// Neither group can catch a version that was never deployed, or that is no +/// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a +/// substitute for it. +abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { + /// Checks one version against itself: derive from its creation code, then + /// require everything it records to agree with the derivation. + /// @param version The version to check. + function checkInternallyConsistent(DeployVersion memory version) internal { + DerivedDeploy memory derived = deriveDeployment(version); + + if (version.storedDeployedAddress != derived.deployedAddress) { + revert StoredAddressMismatch(version.version, version.storedDeployedAddress, derived.deployedAddress); + } + + if (version.storedBytecodeHash != derived.bytecodeHash) { + revert StoredCodeHashMismatch(version.version, version.storedBytecodeHash, derived.bytecodeHash); + } + + bytes32 runtimeCodeHash = keccak256(version.storedRuntimeCode); + if (version.storedBytecodeHash != runtimeCodeHash) { + revert StoredRuntimeCodeHashMismatch(version.version, version.storedBytecodeHash, runtimeCodeHash); + } + } + + /// Checks the candidate against the source this repo compiles. + /// @param candidate The candidate to check. + function checkAnchoredToSource(DeployCandidate memory candidate) internal pure { + if (keccak256(candidate.snapshot.creationCode) != keccak256(candidate.sourceCreationCode)) { + revert CandidateSourceMismatch( + candidate.snapshot.version, + keccak256(candidate.snapshot.creationCode), + keccak256(candidate.sourceCreationCode) + ); + } + } + + /// Every recorded version MUST be internally consistent: what it records is + /// what its own creation code derives. + function testDeployPinsInternallyConsistent() external { + DeployVersion[] memory versions = allVersions(); + for (uint256 i = 0; i < versions.length; i++) { + checkInternallyConsistent(versions[i]); + } + } + + /// The candidate MUST be a snapshot of the contract this repo compiles, not + /// of some other contract that happens to be internally consistent. + function testDeployPinsCandidateAnchoredToSource() external pure { + checkAnchoredToSource(candidateVersion()); + } +} diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol index 97e24e8..9d66b73 100644 --- a/src/lib/LibAddressRegistryDeploy.sol +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -10,7 +10,9 @@ pragma solidity ^0.8.25; /// /// Both values are derived from the creation code this repo compiles, under /// this repo's own compiler settings, and are checked against it by -/// `AddressRegistryDeployPinsTest` — the contract, the settings that compile it +/// `AddressRegistryDeployPinsOfflineTest` — the contract, the settings that +/// compile it +/// and the pins that describe it are all here, so there is no boundary across /// and the pins that describe it are all here, so there is no boundary across /// which they can silently diverge. /// @@ -18,7 +20,7 @@ pragma solidity ^0.8.25; /// moves both values. /// /// HAND-WRITTEN FOR NOW. From the first `sol-v*` release this file is -/// regenerated by `script/BuildPointers.sol`, aliasing the frozen +/// regenerated by `script/Build.sol`, aliasing the frozen /// `src/generated//AddressRegistry.pointers.sol` snapshot so that snapshot /// is the single source of truth. It already lives at the import path the /// generated version will occupy, so consumers' imports do not move. No diff --git a/test/abstract/MockDeployVersions.sol b/test/abstract/MockDeployVersions.sol new file mode 100644 index 0000000..349e4e3 --- /dev/null +++ b/test/abstract/MockDeployVersions.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../src/abstract/RainDeployVerifyBase.sol"; +import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 +} from "../fixtures/0_0_1/MockDeployable.pointers.sol"; +import { + BYTECODE_HASH as MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, + CREATION_CODE as MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, + DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, + RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 +} from "../fixtures/0_0_2/MockDeployableV2.pointers.sol"; + +/// @title MockDeployVersions +/// @notice A deploy repo's version declaration, as a fixture: two frozen +/// releases plus a candidate tracking `MockDeployableV2`. +/// +/// It is declared once, here, and inherited into one `RainDeployVerifyOffline` +/// contract and one `RainDeployVerifyChain` contract. That is the shape every +/// consumer has, and it is what keeps the two groups in separate contracts +/// without the versions being written out twice. +/// +/// Everything about it is deliberate: +/// +/// - `0_0_1` and `0_0_2` take their creation code from frozen literal +/// constants, never from `type(X).creationCode`. A release records what was +/// deployed; that the contract still exists in this repo is incidental. +/// - `0_0_2` and the candidate are the same bytes, which is what a repo looks +/// like between a release and the next source change. Two versions therefore +/// derive one address. +/// - The candidate takes its creation code from source, because it has no +/// frozen snapshot to take it from — the state `AddressRegistry` is in. +abstract contract MockDeployVersions is RainDeployVerifyBase { + /// @inheritdoc RainDeployVerifyBase + function releasedVersions() internal pure override returns (DeployVersion[] memory versions) { + versions = new DeployVersion[](2); + versions[0] = DeployVersion({ + version: "0_0_1", + creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + }); + versions[1] = DeployVersion({ + version: "0_0_2", + creationCode: MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, + storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, + storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, + storedRuntimeCode: MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 + }); + } + + /// @inheritdoc RainDeployVerifyBase + function candidateVersion() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeployVersion({ + version: "candidate", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, + storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, + storedRuntimeCode: type(MockDeployableV2).runtimeCode + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } +} diff --git a/test/fixtures/0_0_1/MockDeployable.pointers.sol b/test/fixtures/0_0_1/MockDeployable.pointers.sol new file mode 100644 index 0000000..9e07d23 --- /dev/null +++ b/test/fixtures/0_0_1/MockDeployable.pointers.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// Hand-written fixture, deliberately shaped exactly like the frozen per-release +// snapshot a deploy repo generates at `src/generated//.pointers.sol`. +// It exists so the verification abstracts are exercised against the real shape +// consumers have — four literal constants and no reference to any source +// contract — rather than only against values re-derived at test time, which +// would check the derivation against itself. +// +// A released snapshot is FROZEN. `CREATION_CODE` is a literal here rather than +// `type(MockDeployable).creationCode` for exactly the reason a released tag is +// never anchored to current source: the snapshot records what was deployed, and +// nothing requires the contract that produced it to still exist. +// +// The values are `MockDeployable` under this repo's pinned compiler settings. +// They are pins, so a settings change moves them and turns the suite red until +// they follow, which is the point. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/fixtures/0_0_2/MockDeployableV2.pointers.sol b/test/fixtures/0_0_2/MockDeployableV2.pointers.sol new file mode 100644 index 0000000..fa7f83e --- /dev/null +++ b/test/fixtures/0_0_2/MockDeployableV2.pointers.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// Hand-written fixture in the frozen per-release snapshot shape, as +// `0_0_1/MockDeployable.pointers.sol` explains. A SECOND release, for two +// reasons neither of which one release covers. +// +// It is a different contract from `0_0_1`, so the two releases derive different +// addresses, which is what a repo with a version history actually looks like. +// +// It records the same creation code the candidate compiles, which is the +// ordinary state of a deploy repo between a release and the next source change: +// the newest release and the candidate ARE the same bytes, so they derive the +// same address, and a derivation that could not run twice for one address would +// break on the common case rather than an exotic one. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol new file mode 100644 index 0000000..e0db485 --- /dev/null +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import { + CodeHashMismatchOnNetwork, + NotDeployedOnNetwork, + RainDeployVerifyChain +} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockDeployVersions} from "../../abstract/MockDeployVersions.sol"; +import { + BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 +} from "../../fixtures/0_0_1/MockDeployable.pointers.sol"; +import { + DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, + RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 +} from "../../fixtures/0_0_2/MockDeployableV2.pointers.sol"; + +/// @title RainDeployVerifyChainTest +/// @notice `RainDeployVerifyChain` inherited by a fixture repo whose versions +/// are made live on every network by `setUp`, so the inherited +/// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks all +/// five supported networks and finds all three versions. +/// +/// `setUp` places the code with a persistent `vm.etch` rather than pointing the +/// fixture at some real deployment in another repo. A real one would make this +/// suite fail whenever that unrelated deployment moved — which is precisely the +/// signal this group exists to raise for its own repo, and precisely the wrong +/// thing to import into this one. +/// +/// The etch does not make the passing case circular. It writes the runtime code +/// the compiler emits, while the expectation is derived independently by running +/// the recorded CREATION code through the Zoltu factory. That the two agree is +/// the assertion. `testChainCodeHashMismatchReverts` is what proves it: it +/// leaves the etch in place and changes only the code, and the check still +/// fails, which it could not do if the expectation were read from the etch. +contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain { + /// Makes every fixture version live on every fork, which is what the + /// inherited test then verifies. Persistent so it survives each + /// `createSelectFork` inside the loop. + function setUp() external { + vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); + vm.makePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); + vm.etch(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); + vm.makePersistent(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2); + } + + /// External wrapper for `checkDeployedOnNetwork` so `vm.expectRevert` works + /// at the correct call depth. + /// @param network The network name, for the error only. + /// @param derived The derivation to check for. + function externalCheckDeployedOnNetwork(string memory network, DerivedDeploy memory derived) external view { + checkDeployedOnNetwork(network, derived); + } + + /// A version that is not on a network MUST fail, naming the network, the + /// version and the address. This is the whole reason the group exists: a + /// release that reached four chains of five, or a chain added after a + /// release that therefore never got it, is invisible to every other check. + function testChainNotDeployedReverts() external { + // Present locally, but no longer carried onto forks. + vm.revokePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, + LibRainDeploy.ARBITRUM_ONE, + "0_0_1", + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + ) + ); + this.testDeployPinsLiveOnEverySupportedNetwork(); + } + + /// EVERY version MUST be checked, not just the first one the matrix + /// reaches. The version missing here is the second and third, so a matrix + /// that stopped after the first version would pass. + function testChainNotDeployedRevertsForALaterVersion() external { + vm.revokePersistent(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, + LibRainDeploy.ARBITRUM_ONE, + "0_0_2", + MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2 + ) + ); + this.testDeployPinsLiveOnEverySupportedNetwork(); + } + + /// EVERY network MUST be forked, not just the first. A completed matrix + /// leaves the LAST supported network selected, which a matrix that stopped + /// early cannot do — and unlike a missing deployment, nothing about the + /// passing case itself distinguishes the two. + function testChainMatrixReachesTheLastSupportedNetwork() external { + string[] memory networks = LibRainDeploy.supportedNetworks(); + + uint256 lastForkId = vm.createSelectFork(networks[networks.length - 1]); + (lastForkId); + uint256 lastChainId = block.chainid; + + // Start somewhere the matrix does not end, so arriving at `lastChainId` + // means the matrix moved rather than that it never forked at all. + uint256 firstForkId = vm.createSelectFork(networks[0]); + (firstForkId); + assertNotEq(block.chainid, lastChainId); + + this.testDeployPinsLiveOnEverySupportedNetwork(); + + assertEq(block.chainid, lastChainId); + } + + /// Code on a network that is not the code the version's creation code + /// produces MUST fail hard, naming the network and BOTH hashes. + /// + /// This is also the shape of a chain-dependent runtime — a constructor that + /// reads `block.chainid` deploys different code per network — which is a + /// defect in the contract rather than something to record a hash per chain + /// for. + /// + /// It doubles as the proof that the expectation is derived rather than + /// observed: the wrong code is etched at the address the check reads, so if + /// the derivation took its expectation from there this would pass. + function testChainCodeHashMismatchReverts() external { + vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, hex"6001"); + + vm.expectRevert( + abi.encodeWithSelector( + CodeHashMismatchOnNetwork.selector, + LibRainDeploy.ARBITRUM_ONE, + "0_0_1", + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + keccak256(hex"6001") + ) + ); + this.testDeployPinsLiveOnEverySupportedNetwork(); + } + + /// The network in the failure MUST be the network that failed, not a fixed + /// string. Checked on a different network from the one the matrix reaches + /// first, against a derivation that is deliberately expecting the wrong + /// hash. + function testChainFailureNamesTheNetworkChecked() external { + vm.createSelectFork(LibRainDeploy.BASE); + + DerivedDeploy memory derived = DerivedDeploy({ + version: "0_0_1", deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, bytecodeHash: bytes32(uint256(1)) + }); + + vm.expectRevert( + abi.encodeWithSelector( + CodeHashMismatchOnNetwork.selector, + LibRainDeploy.BASE, + "0_0_1", + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + bytes32(uint256(1)), + MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + ) + ); + this.externalCheckDeployedOnNetwork(LibRainDeploy.BASE, derived); + } + + /// Deriving MUST NOT disturb what is deployed at the derived address. The + /// derivation clears that address to run the creation code there, so if it + /// did not put things back, a persistent deployment would be destroyed + /// before the networks were ever read — and the matrix would report every + /// version missing everywhere. + function testDerivationRestoresCodeAtDerivedAddress() external { + assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); + assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); + + DerivedDeploy[] memory derived = deriveDeployments(allVersions()); + assertEq(derived.length, 3); + + assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); + assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); + } + + /// The matrix MUST cover every supported network, not a subset one repo + /// happened to list. A network added to `LibRainDeploy.supportedNetworks()` + /// is checked for every recorded version from the moment it is added, which + /// is the case no per-chain test function can cover. + function testChainMatrixCoversEverySupportedNetwork() external { + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + // Live on every network except this one. + vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); + vm.makePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); + + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, hex""); + + DerivedDeploy memory derived = DerivedDeploy({ + version: "0_0_1", + deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + bytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + }); + + vm.expectRevert( + abi.encodeWithSelector( + NotDeployedOnNetwork.selector, networks[i], "0_0_1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + ) + ); + this.externalCheckDeployedOnNetwork(networks[i], derived); + } + } +} diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifyOffline.t.sol new file mode 100644 index 0000000..8030170 --- /dev/null +++ b/test/src/abstract/RainDeployVerifyOffline.t.sol @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeployVersion, ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import { + CandidateSourceMismatch, + RainDeployVerifyOffline, + StoredAddressMismatch, + StoredCodeHashMismatch, + StoredRuntimeCodeHashMismatch +} from "../../../src/abstract/RainDeployVerifyOffline.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockDeployVersions} from "../../abstract/MockDeployVersions.sol"; +import {MockDeployable} from "../../concrete/MockDeployable.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 +} from "../../fixtures/0_0_1/MockDeployable.pointers.sol"; + +/// @title RainDeployVerifyOfflineTest +/// @notice `RainDeployVerifyOffline` inherited by a fixture repo, so the +/// inherited tests themselves are the passing case: `MockDeployVersions` +/// declares two frozen releases and a candidate, and +/// `testDeployPinsInternallyConsistent` / +/// `testDeployPinsCandidateAnchoredToSource` run over them here exactly as they +/// would in a consumer. +/// +/// The rest is what each group CATCHES, and — for the internal group — what it +/// provably does not. Every case drives the same internal functions the +/// inherited tests do, through external wrappers so `vm.expectRevert` lands at +/// the right call depth, with the fixture data deliberately broken one field at +/// a time. +contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffline { + /// External wrapper for `checkInternallyConsistent` so `vm.expectRevert` + /// works at the correct call depth. + /// @param version The version to check. + function externalCheckInternallyConsistent(DeployVersion memory version) external { + checkInternallyConsistent(version); + } + + /// External wrapper for `checkAnchoredToSource` so `vm.expectRevert` works + /// at the correct call depth. + /// @param candidate The candidate to check. + function externalCheckAnchoredToSource(DeployCandidate memory candidate) external pure { + checkAnchoredToSource(candidate); + } + + /// A consistent snapshot of the WRONG contract: every recorded field is + /// `MockDeployable`'s and they all agree with each other, but it is + /// presented as the candidate for a repo whose source is + /// `MockDeployableV2`. This is the shape of a snapshot generated from a + /// stale build, or from the wrong contract in a repo with several. + /// @return The wrong-contract candidate. + function wrongContractCandidate() internal pure returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeployVersion({ + version: "candidate", + creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// The frozen `0_0_1` release, which every negative case below breaks one + /// field of. + /// @return The consistent `0_0_1` version. + function consistentVersion() internal pure returns (DeployVersion memory) { + return DeployVersion({ + version: "0_0_1", + creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + }); + } + + /// A recorded address that is not the one the recorded creation code + /// derives MUST fail, naming the version and both addresses. This is the + /// hand-edited constant, and the address copied from the wrong tag. + function testStoredAddressMismatchReverts() external { + DeployVersion memory version = consistentVersion(); + version.storedDeployedAddress = address(0xdead); + + vm.expectRevert( + abi.encodeWithSelector( + StoredAddressMismatch.selector, "0_0_1", address(0xdead), MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + ) + ); + this.externalCheckInternallyConsistent(version); + } + + /// A recorded code hash that is not the one the recorded creation code + /// produces MUST fail, naming the version and both hashes. This is a + /// snapshot regenerated for one field and not the others. + function testStoredCodeHashMismatchReverts() external { + DeployVersion memory version = consistentVersion(); + version.storedBytecodeHash = bytes32(uint256(1)); + + vm.expectRevert( + abi.encodeWithSelector( + StoredCodeHashMismatch.selector, "0_0_1", bytes32(uint256(1)), MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + ) + ); + this.externalCheckInternallyConsistent(version); + } + + /// Recorded runtime code that does not hash to the code hash recorded + /// beside it MUST fail, naming the version and both hashes. The address and + /// the code hash still agree with the creation code here, so this is the + /// only check standing between a corrupted `RUNTIME_CODE` and a green + /// suite. + function testStoredRuntimeCodeHashMismatchReverts() external { + DeployVersion memory version = consistentVersion(); + version.storedRuntimeCode = hex"00"; + + vm.expectRevert( + abi.encodeWithSelector( + StoredRuntimeCodeHashMismatch.selector, "0_0_1", MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, keccak256(hex"00") + ) + ); + this.externalCheckInternallyConsistent(version); + } + + /// The internal group MUST NOT catch a consistent snapshot of the wrong + /// contract, and this pins that it does not. It is not a gap to be closed + /// there: every internal check asks the recorded bytes to agree with each + /// other, and the wrong contract's bytes agree with each other perfectly. + /// + /// Pinning the miss is what stops the internal group from being read as + /// covering the source-anchored one, and what makes the next test the only + /// thing standing between a stale snapshot and a green suite. + function testWrongContractSnapshotPassesInternalConsistency() external { + DeployCandidate memory candidate = wrongContractCandidate(); + + // It really is the wrong contract: the recorded creation code is not + // the creation code this repo compiles for the candidate. + assertNotEq(keccak256(candidate.snapshot.creationCode), keccak256(type(MockDeployableV2).creationCode)); + assertEq(keccak256(candidate.snapshot.creationCode), keccak256(type(MockDeployable).creationCode)); + + // Every internal check passes anyway. + this.externalCheckInternallyConsistent(candidate.snapshot); + } + + /// The source-anchored group MUST catch exactly the snapshot the internal + /// group just let through, naming the candidate and both creation code + /// hashes. This is the only check in the whole suite that can. + function testWrongContractSnapshotCaughtBySource() external { + DeployCandidate memory candidate = wrongContractCandidate(); + + vm.expectRevert( + abi.encodeWithSelector( + CandidateSourceMismatch.selector, + "candidate", + keccak256(MOCK_DEPLOYABLE_CREATION_CODE_0_0_1), + keccak256(type(MockDeployableV2).creationCode) + ) + ); + this.externalCheckAnchoredToSource(candidate); + } + + /// A candidate whose recorded creation code IS the source's MUST pass, so + /// the previous test is discriminating rather than a check that always + /// fails. + function testCandidateAnchoredToSourcePasses() external view { + this.externalCheckAnchoredToSource(candidateVersion()); + } + + /// Two versions that record the SAME creation code MUST both derive, which + /// is the ordinary state of a repo between a release and the next source + /// change. `0_0_2` and the candidate are the same bytes and therefore the + /// same address, and the whole set still passes. + function testVersionsSharingCreationCodeAllDerive() external { + DeployVersion[] memory versions = allVersions(); + assertEq(versions.length, 3); + assertEq(versions[1].storedDeployedAddress, versions[2].storedDeployedAddress); + assertEq(keccak256(versions[1].creationCode), keccak256(versions[2].creationCode)); + + // Neither derivation is disturbed by the other. + this.externalCheckInternallyConsistent(versions[1]); + this.externalCheckInternallyConsistent(versions[2]); + } + + /// The pure address formula and the factory bytecode the derivation etches + /// MUST agree, and a disagreement MUST be named rather than left to surface + /// as a code hash read from an address nothing was deployed to. Forced here + /// by making the factory report an address it did not deploy to, which is + /// otherwise unreachable — the derivation etches the factory bytecode + /// itself, so only a `LibRainDeploy` whose constant and formula had drifted + /// apart could produce it. + function testZoltuDerivationMismatchReverts() external { + DeployVersion memory version = consistentVersion(); + + // The factory answers with an address that does have code, but is not + // the one the creation code derives. + vm.mockCall( + LibRainDeploy.ZOLTU_FACTORY, + MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + abi.encodePacked(bytes20(LibRainDeploy.ZOLTU_FACTORY)) + ); + + vm.expectRevert( + abi.encodeWithSelector( + ZoltuDerivationMismatch.selector, + "0_0_1", + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + LibRainDeploy.ZOLTU_FACTORY + ) + ); + this.externalCheckInternallyConsistent(version); + } + + /// The derivation MUST leave nothing behind. A local deploy that survived + /// would be compared against itself by the chain-anchored group, and every + /// network would pass whether or not anything is deployed there. + function testDerivationLeavesNoCodeBehind() external { + DeployVersion[] memory versions = allVersions(); + for (uint256 i = 0; i < versions.length; i++) { + assertEq(versions[i].storedDeployedAddress.code.length, 0); + } + + this.externalCheckInternallyConsistent(versions[0]); + + for (uint256 i = 0; i < versions.length; i++) { + assertEq(versions[i].storedDeployedAddress.code.length, 0); + } + } +} diff --git a/test/src/concrete/AddressRegistryDeployPins.t.sol b/test/src/concrete/AddressRegistryDeployPins.t.sol index 7501714..a9ecc14 100644 --- a/test/src/concrete/AddressRegistryDeployPins.t.sol +++ b/test/src/concrete/AddressRegistryDeployPins.t.sol @@ -2,48 +2,88 @@ // 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 "../../../src/lib/LibRainDeploy.sol"; -import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; +import {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {RainDeployVerifyOffline} from "../../../src/abstract/RainDeployVerifyOffline.sol"; import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; -/// @title AddressRegistryDeployPinsTest -/// @notice `LibAddressRegistryDeploy` pins the deterministic address and code -/// hash of `AddressRegistry`, and `LibAddressRegistry` resolves names through -/// those pins. Both are a pure function of the creation code, which is a pure -/// function of this repo's compiler settings — and the contract, the settings -/// and the pins are all in this repo, so this suite closes the loop rather than -/// asserting across a boundary. +/// @title AddressRegistryDeployVersions +/// @notice Every version of `AddressRegistry` this repo records, declared once +/// and inherited into one offline contract and one chain contract below. /// -/// The root authority is a constant in that creation code, so changing the root -/// moves both pins and turns this suite red until they follow. -contract AddressRegistryDeployPinsTest is Test { - /// The pins MUST be derivable from this source without deploying anything: - /// the Zoltu factory is `CREATE2` over its calldata with a zero salt, so the - /// address is a pure function of the creation code, and the code hash is - /// `keccak256` of the runtime code that creation code leaves behind. - function testAddressRegistryPinsDeriveFromThisSource() external pure { - assertEq( - LibRainDeploy.zoltuAddress(type(AddressRegistry).creationCode), - LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS - ); - assertEq( - keccak256(type(AddressRegistry).runtimeCode), LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH - ); +/// This declaration is the whole of what a deploy repo writes. The verification +/// itself — the derivation from creation code, the comparisons against what is +/// recorded, the source anchor, the networks matrix — is `rain-deploy`'s and is +/// inherited rather than restated. There is deliberately nothing per version +/// and nothing per network here, so neither a new release nor a new supported +/// network adds a test. +/// +/// From the first `sol-v*` release this declaration is what `script/Build.sol` +/// generates, so the frozen snapshot a release cuts is in the enumeration the +/// moment it exists rather than the next time somebody remembers to add a test. +abstract contract AddressRegistryDeployVersions is RainDeployVerifyBase { + /// @inheritdoc RainDeployVerifyBase + /// @dev Empty: no release has been cut, so no snapshot is frozen. + /// `src/generated//` is append-only and `ADDRESS_REGISTRY_ROOT` is + /// still a placeholder, so a snapshot written now could never be corrected. + function releasedVersions() internal pure override returns (DeployVersion[] memory) { + return new DeployVersion[](0); } - /// Actually deploying this contract's creation code through the Zoltu - /// factory MUST land at the pinned address with the pinned code hash, so the - /// derivation is checked against the factory rather than only against - /// itself. - function testAddressRegistryDeploysToPinnedAddress() external { - LibRainDeploy.etchZoltuFactory(vm); - - address deployed = LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode); - - assertEq(deployed, LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS); - assertEq(deployed.codehash, LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); - assertEq(keccak256(deployed.code), LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH); + /// @inheritdoc RainDeployVerifyBase + /// @dev The pins in `LibAddressRegistryDeploy` are hand-written literals, + /// and they are what the internal group checks the derivation against. + /// + /// The creation code and runtime code come from source because nothing + /// records them yet — there is no `src/generated/candidate/` pointers file + /// to read them from. Until there is, the source anchor compares source + /// against itself and can only pass: what it protects against is a RECORDED + /// creation code drifting from the source it claims to be, and there is no + /// recorded creation code here to drift. The address and code hash pins ARE + /// recorded, and those are checked for real. + function candidateVersion() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeployVersion({ + version: "candidate", + creationCode: type(AddressRegistry).creationCode, + storedDeployedAddress: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + storedRuntimeCode: type(AddressRegistry).runtimeCode + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); } } + +/// @title AddressRegistryDeployPinsOfflineTest +/// @notice The deploy-pin assertions for `AddressRegistry` that need no +/// network. The pins are a pure function of the creation code, which is a pure +/// function of this repo's compiler settings — and the contract, the settings +/// and the pins are all in this repo, so this closes the loop rather than +/// asserting across a boundary. +/// +/// The root authority is a constant in that creation code, so changing the root +/// moves both pins and turns this red until they follow. +contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeployVersions, RainDeployVerifyOffline {} + +/// @title AddressRegistryDeployPinsChainTest +/// @notice Whether `AddressRegistry` is actually live, with the code this repo +/// compiles, on every supported network. +/// +/// It is not. `AddressRegistry` has never been deployed and +/// `ADDRESS_REGISTRY_ROOT` is still a placeholder, so this fails on the first +/// network with `NotDeployedOnNetwork` and keeps failing until the contract is +/// deployed to all five. +/// +/// That failure is the check working. "Nothing is deployed at the address +/// `LibAddressRegistry` reads" is true, it is the single most important fact +/// about these pins, and no offline assertion can discover it — a perfectly +/// consistent set of pins for a contract that exists nowhere passes every one +/// of them. A green here would only mean nobody asked. +/// +/// It is a separate contract from the offline assertions precisely so that it +/// says this and nothing more: `forge test --no-match-contract Chain` still +/// verifies everything that holds offline, whether the deployment is missing or +/// the RPC endpoints are merely unreachable. +contract AddressRegistryDeployPinsChainTest is AddressRegistryDeployVersions, RainDeployVerifyChain {} From 4662b770c9957f20bc3bc0f840f816b6713408d9 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 13:19:21 +0000 Subject: [PATCH 07/29] fix(verify): one contract per file, and keep slither on deployable code only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rainix-sol-single-contract` counts `abstract contract` too, so the version declaration and the two test contracts that inherit it are three files now: `test/abstract/AddressRegistryDeployVersions.sol` and one `.t.sol` each. `slither.config.json` filters the three `RainDeployVerify*` files by name. They are inherited by test contracts and never deployed, so every slither detector is about a risk they do not have; the two it raised were "an abstract does not implement its own virtuals" and "a cheatcode is called in a loop". Named rather than the whole of `src/abstract/`, so a future deployable abstract there is still analyzed, and by filter rather than by disabling the detectors, which would have turned them off for `AddressRegistry` as well. `testDerivationRestoresCodeAtDerivedAddress` now checks the nonce as well as the code, and the nonce is the part that discriminates: a local deploy that survived would leave the SAME runtime code, so code alone cannot tell "put back" from "deployed over the top" — but `CREATE2` leaves nonce 1 where a restored etch is at nonce 0. Without it, dropping the `revertToState` survived this test. --- CLAUDE.md | 8 +++ slither.config.json | 2 +- .../AddressRegistryDeployVersions.sol} | 51 ++++--------------- test/src/abstract/RainDeployVerifyChain.t.sol | 12 ++++- .../AddressRegistryDeployPinsChain.t.sol | 28 ++++++++++ .../AddressRegistryDeployPinsOffline.t.sol | 23 +++++++++ 6 files changed, 80 insertions(+), 44 deletions(-) rename test/{src/concrete/AddressRegistryDeployPins.t.sol => abstract/AddressRegistryDeployVersions.sol} (50%) create mode 100644 test/src/concrete/AddressRegistryDeployPinsChain.t.sol create mode 100644 test/src/concrete/AddressRegistryDeployPinsOffline.t.sol diff --git a/CLAUDE.md b/CLAUDE.md index 1efbc9a..ef38771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,6 +157,14 @@ the same on every network, so a constructor reading `block.chainid` or similar is a DEFECT: it fails hard, naming the chain and both hashes. There is deliberately no per-chain code hash to record. +These three are the only `src/` files `slither.config.json` filters out, by +name. They are inherited by test contracts and never deployed, so slither's +detectors — all of which are about deployed-code risk — have nothing to say +about them except that an abstract does not implement its own virtuals and that +a cheatcode is called in a loop. The filter names the files rather than the +directory, so a future `src/abstract/` file that IS deployable is still +analyzed. + The libraries are designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, expected addresses, expected code hashes, and dependency lists. diff --git a/slither.config.json b/slither.config.json index 745db00..674d2ab 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-", + "filter_paths": "dependencies/forge-std-|src/abstract/RainDeployVerify", "detectors_to_exclude": "assembly,low-level-calls" } diff --git a/test/src/concrete/AddressRegistryDeployPins.t.sol b/test/abstract/AddressRegistryDeployVersions.sol similarity index 50% rename from test/src/concrete/AddressRegistryDeployPins.t.sol rename to test/abstract/AddressRegistryDeployVersions.sol index a9ecc14..8cc0c36 100644 --- a/test/src/concrete/AddressRegistryDeployPins.t.sol +++ b/test/abstract/AddressRegistryDeployVersions.sol @@ -2,22 +2,21 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../../src/abstract/RainDeployVerifyBase.sol"; -import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; -import {RainDeployVerifyOffline} from "../../../src/abstract/RainDeployVerifyOffline.sol"; -import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; -import {LibAddressRegistryDeploy} from "../../../src/lib/LibAddressRegistryDeploy.sol"; +import {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../src/abstract/RainDeployVerifyBase.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../../src/lib/LibAddressRegistryDeploy.sol"; /// @title AddressRegistryDeployVersions /// @notice Every version of `AddressRegistry` this repo records, declared once -/// and inherited into one offline contract and one chain contract below. +/// and inherited by `AddressRegistryDeployPinsOfflineTest` and +/// `AddressRegistryDeployPinsChainTest`. /// /// This declaration is the whole of what a deploy repo writes. The verification /// itself — the derivation from creation code, the comparisons against what is -/// recorded, the source anchor, the networks matrix — is `rain-deploy`'s and is -/// inherited rather than restated. There is deliberately nothing per version -/// and nothing per network here, so neither a new release nor a new supported -/// network adds a test. +/// recorded, the source anchor, the networks matrix — is +/// `src/abstract/RainDeployVerify*.sol`'s and is inherited rather than +/// restated. There is deliberately nothing per version and nothing per network +/// here, so neither a new release nor a new supported network adds a test. /// /// From the first `sol-v*` release this declaration is what `script/Build.sol` /// generates, so the frozen snapshot a release cuts is in the enumeration the @@ -55,35 +54,3 @@ abstract contract AddressRegistryDeployVersions is RainDeployVerifyBase { }); } } - -/// @title AddressRegistryDeployPinsOfflineTest -/// @notice The deploy-pin assertions for `AddressRegistry` that need no -/// network. The pins are a pure function of the creation code, which is a pure -/// function of this repo's compiler settings — and the contract, the settings -/// and the pins are all in this repo, so this closes the loop rather than -/// asserting across a boundary. -/// -/// The root authority is a constant in that creation code, so changing the root -/// moves both pins and turns this red until they follow. -contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeployVersions, RainDeployVerifyOffline {} - -/// @title AddressRegistryDeployPinsChainTest -/// @notice Whether `AddressRegistry` is actually live, with the code this repo -/// compiles, on every supported network. -/// -/// It is not. `AddressRegistry` has never been deployed and -/// `ADDRESS_REGISTRY_ROOT` is still a placeholder, so this fails on the first -/// network with `NotDeployedOnNetwork` and keeps failing until the contract is -/// deployed to all five. -/// -/// That failure is the check working. "Nothing is deployed at the address -/// `LibAddressRegistry` reads" is true, it is the single most important fact -/// about these pins, and no offline assertion can discover it — a perfectly -/// consistent set of pins for a contract that exists nowhere passes every one -/// of them. A green here would only mean nobody asked. -/// -/// It is a separate contract from the offline assertions precisely so that it -/// says this and nothing more: `forge test --no-match-contract Chain` still -/// verifies everything that holds offline, whether the deployment is missing or -/// the RPC endpoints are merely unreachable. -contract AddressRegistryDeployPinsChainTest is AddressRegistryDeployVersions, RainDeployVerifyChain {} diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index e0db485..64aabaf 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -166,20 +166,30 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain this.externalCheckDeployedOnNetwork(LibRainDeploy.BASE, derived); } - /// Deriving MUST NOT disturb what is deployed at the derived address. The + /// Deriving MUST leave the derived address exactly as it found it. The /// derivation clears that address to run the creation code there, so if it /// did not put things back, a persistent deployment would be destroyed /// before the networks were ever read — and the matrix would report every /// version missing everywhere. + /// + /// The nonce is checked as well as the code, and it is the part that + /// discriminates. A local deploy that survived would leave the SAME runtime + /// code sitting there, so comparing code alone cannot tell "put back" from + /// "deployed over the top" — but a `CREATE2` deploy leaves the account at + /// nonce 1, while a restored etch is at nonce 0. function testDerivationRestoresCodeAtDerivedAddress() external { assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); + assertEq(vm.getNonce(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1), 0); + assertEq(vm.getNonce(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2), 0); DerivedDeploy[] memory derived = deriveDeployments(allVersions()); assertEq(derived.length, 3); assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); + assertEq(vm.getNonce(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1), 0); + assertEq(vm.getNonce(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2), 0); } /// The matrix MUST cover every supported network, not a subset one repo diff --git a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol new file mode 100644 index 0000000..2deedbc --- /dev/null +++ b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeployVersions.sol"; + +/// @title AddressRegistryDeployPinsChainTest +/// @notice Whether `AddressRegistry` is actually live, with the code this repo +/// compiles, on every supported network. +/// +/// It is not. `AddressRegistry` has never been deployed and +/// `ADDRESS_REGISTRY_ROOT` is still a placeholder, so this fails on the first +/// network with `NotDeployedOnNetwork` and keeps failing until the contract is +/// deployed to all five. +/// +/// That failure is the check working. "Nothing is deployed at the address +/// `LibAddressRegistry` reads" is true, it is the single most important fact +/// about these pins, and no offline assertion can discover it — a perfectly +/// consistent set of pins for a contract that exists nowhere passes every one +/// of them. A green here would only mean nobody asked. +/// +/// It is a separate contract from `AddressRegistryDeployPinsOfflineTest` +/// precisely so that it says this and nothing more: `forge test +/// --no-match-contract Chain` still verifies everything that holds offline, +/// whether the deployment is missing or the RPC endpoints are merely +/// unreachable. +contract AddressRegistryDeployPinsChainTest is AddressRegistryDeployVersions, RainDeployVerifyChain {} diff --git a/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol b/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol new file mode 100644 index 0000000..b20bfe8 --- /dev/null +++ b/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {RainDeployVerifyOffline} from "../../../src/abstract/RainDeployVerifyOffline.sol"; +import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeployVersions.sol"; + +/// @title AddressRegistryDeployPinsOfflineTest +/// @notice The deploy-pin assertions for `AddressRegistry` that need no +/// network: what `LibAddressRegistryDeploy` records is what the creation code +/// this repo compiles derives, and the candidate is a snapshot of that source +/// rather than of some other contract. +/// +/// The pins are a pure function of the creation code, which is a pure function +/// of this repo's compiler settings — and the contract, the settings and the +/// pins are all in this repo, so this closes the loop rather than asserting +/// across a boundary. The root authority is a constant in that creation code, +/// so changing the root moves both pins and turns this red until they follow. +/// +/// Both assertions are inherited. There is nothing to write here, which is the +/// point: `AddressRegistryDeployVersions` says which versions exist and +/// `RainDeployVerifyOffline` says what is true of them. +contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeployVersions, RainDeployVerifyOffline {} From 226befc8b30c1b5a59b58755880e61633d84b4af Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 13:43:37 +0000 Subject: [PATCH 08/29] feat(deploy): the on-chain deploy this repo had no way to run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AddressRegistry` had pins, a generator and a chain-verification test, and nothing that could put it on chain. `package-release.yaml` already assumes a manual deploy runs BEFORE the tag — `rainix-tag-release` verifies live chains against fresh pins and never broadcasts — so that step had to exist somewhere and did not. `script/Deploy.sol` broadcasts `AddressRegistry` to every network in `supportedNetworks()`, dispatching on `DEPLOYMENT_SUITE` (`address-registry`). `.github/workflows/manual-sol-artifacts.yaml` is `workflow_dispatch` only: broadcasting is key custody and real money, and no merge or tag should reach it. Two deliberate differences from rain.factory.deploy's pair, which is the reference: - No `sDepCodeHashes` mapping. That ninth argument belongs to `rain-deploy-0.1.3`'s `deployAndBroadcast`, which rain.factory.deploy pins. This repo IS rain.deploy, and its own signature takes eight. - The suite is read before the key, so a mistyped `suite:` input fails in seconds naming what it should have been, rather than failing on a missing `DEPLOYMENT_KEY` and sending the reader after the wrong thing. `foundry.toml` gains `[etherscan]`. `rainix-manual-sol-artifacts` passes `--verify` by default and exports exactly these variable names; without the section a deploy broadcasts and then fails with no API key configured for the chain, after spending the gas. Nothing has been dispatched and nothing has been broadcast. The chain group stays red until someone runs this. --- .github/workflows/manual-sol-artifacts.yaml | 23 +++++++ CLAUDE.md | 19 +++++- README.md | 20 +++++- foundry.toml | 16 +++++ script/Deploy.sol | 69 +++++++++++++++++++++ 5 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/manual-sol-artifacts.yaml create mode 100644 script/Deploy.sol diff --git a/.github/workflows/manual-sol-artifacts.yaml b/.github/workflows/manual-sol-artifacts.yaml new file mode 100644 index 0000000..bd59684 --- /dev/null +++ b/.github/workflows/manual-sol-artifacts.yaml @@ -0,0 +1,23 @@ +name: Manual sol artifacts +# The on-chain deploy, run by hand. This repo carries a deployed concrete +# (`AddressRegistry`) whose address + codehash consumers pin, and +# `package-release.yaml` cuts a release for a deployment that ALREADY exists — +# rainix-tag-release verifies the live chains against freshly generated pins and +# never broadcasts. So the deploy has to happen first, and separately, which is +# this. +# +# Order is: dispatch this, confirm `AddressRegistryDeployPinsChainTest` passes +# on every supported network, then push the `sol-v*` tag. +# +# Deliberately `workflow_dispatch` only. Broadcasting is key custody and real +# money; nothing about a merge or a tag should trigger it. +on: + workflow_dispatch: +jobs: + deploy: + uses: rainlanguage/rainix/.github/workflows/rainix-manual-sol-artifacts.yaml@main + with: + # Passed through as DEPLOYMENT_SUITE; script/Deploy.sol dispatches on it + # and reverts on anything else. + suite: address-registry + secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index ef38771..fbb6911 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,9 @@ nix develop -c rainix-sol-legal ``` CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, -`rainix-sol-static`. +`rainix-sol-static`. There is a fourth workflow, `Manual sol artifacts`, which +is `workflow_dispatch` only and is the on-chain deploy — nothing automatic ever +broadcasts. ## RPC Configuration @@ -114,6 +116,11 @@ address, verifying its code hash first, exactly as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH`. It resolves a name to an address and nothing more: what a consumer resolves a name for, and when, is the consumer's business. +**`script/Deploy.sol`** — the broadcast. Deploys `AddressRegistry` to every +network in `supportedNetworks()` at the address `LibAddressRegistryDeploy` pins, +dispatching on `DEPLOYMENT_SUITE` (`address-registry`) and reverting on anything +else. Run only via the `Manual sol artifacts` workflow. + **`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every deploy repo inherits instead of hand-writing. In `src/`, not `test/`, because `.soldeerignore` excludes `/test` from the published package and a consumer that @@ -189,6 +196,16 @@ expected addresses, expected code hashes, and dependency lists. (`rainix-tag-release`), because this repo carries a deployed concrete whose pins consumers rely on. `[package].version` is the LAST released version and moves only in lockstep with its snapshot. +- **Deploy, then verify, then tag** — in that order, and they are three separate + things. `script/Deploy.sol` broadcasts `AddressRegistry` to every network in + `supportedNetworks()`, dispatched by hand through + `.github/workflows/manual-sol-artifacts.yaml`. Only then can + `AddressRegistryDeployPinsChainTest` pass, and only then is there a deployment + for `rainix-tag-release` to verify pins against — it verifies and publishes, + it never broadcasts. Broadcasting is key custody and real money, so it is + `workflow_dispatch` and nothing else. Deploying is idempotent: a network that + already has the code is skipped, so a partial run is fixed by running it + again. ## License diff --git a/README.md b/README.md index ec7c8a3..7adc21c 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,25 @@ Only the consumer knows where it stored what it resolved, so the consumer supplies the reads (`abi.encodeCall(IOwnable.owner, ())` and the like) and this library supplies the fork loop and the comparison. -## Releases +## Deploying, and then releasing + +Three separate steps, in this order. Nothing automatic ever broadcasts. + +1. **Deploy.** Dispatch the + [`Manual sol artifacts`](.github/workflows/manual-sol-artifacts.yaml) + workflow, which runs `script/Deploy.sol` and broadcasts `AddressRegistry` to + every network in `supportedNetworks()`. `workflow_dispatch` only: this is key + custody and real money, and no merge or tag should be able to trigger it. It + is idempotent — a network that already has the code is skipped — so a partial + run is fixed by running it again rather than by unpicking anything. +2. **Verify.** `AddressRegistryDeployPinsChainTest` passes only once every + supported network has the registry, with the code this repo compiles. It is + red today because step 1 has never been run. +3. **Tag.** Push a `sol-v*` tag. `rainix-tag-release` regenerates the snapshot + for the version the tag names, verifies the live chains against those fresh + pins, publishes to Soldeer and commits the frozen snapshot back to `main`. It + verifies and publishes; it never broadcasts, which is exactly why step 1 + cannot be folded into it. This is a deploy repo: it carries a deployed concrete whose address and codehash consumers pin, so releases are **manual `sol-v*` tags**, not merges. diff --git a/foundry.toml b/foundry.toml index 3d37531..13539eb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -40,9 +40,25 @@ rain-sol-codegen = "0.1.0" [soldeer] recursive_deps = false +# Every alias here is a network script/Deploy.sol broadcasts to and +# RainDeployVerifyChain forks. rainix's rpc-preflight action binds +# _RPC_URL to a candidate that is reachable at the time of the run, +# rather than to one URL that may be dead, so these names are the contract with +# it. [rpc_endpoints] arbitrum = "${ARBITRUM_RPC_URL}" base = "${BASE_RPC_URL}" base_sepolia = "${BASE_SEPOLIA_RPC_URL}" flare = "${FLARE_RPC_URL}" polygon = "${POLYGON_RPC_URL}" + +# `rainix-manual-sol-artifacts` passes `--verify` by default and exports exactly +# these variable names, so a deploy without this section broadcasts and then +# fails with no API key configured for the chain — after spending the gas. One +# entry per `[rpc_endpoints]` alias, because the deploy goes to all of them. +[etherscan] +arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" } +base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" } +base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" } +flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" } +polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" } diff --git a/script/Deploy.sol b/script/Deploy.sol new file mode 100644 index 0000000..7cd0fd5 --- /dev/null +++ b/script/Deploy.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; + +import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../src/lib/LibAddressRegistryDeploy.sol"; +import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; + +/// @dev Hash of the "address-registry" deployment suite string. MUST match the +/// `suite:` input of `.github/workflows/manual-sol-artifacts.yaml`, which is +/// what supplies `DEPLOYMENT_SUITE`. +bytes32 constant DEPLOYMENT_SUITE_ADDRESS_REGISTRY = keccak256("address-registry"); + +/// @title Deploy +/// @notice Broadcasts `AddressRegistry` to every network in +/// `LibRainDeploy.supportedNetworks()`, at the deterministic address +/// `LibAddressRegistryDeploy` pins. +/// +/// This is the missing half of the deploy lifecycle `package-release.yaml` +/// assumes: `rainix-tag-release` verifies and publishes pins for a deployment +/// that already exists, so something has to put it on chain first, and a +/// `sol-v*` tag is not it. Run manually via the `Manual sol artifacts` +/// workflow, before tagging. +/// +/// Deploying is idempotent by construction. `deployToNetworks` checks the +/// derived address against the creation code before it forks anything, then +/// skips any network that already has code there, so a partial run — three +/// chains of five, one RPC down — is fixed by running it again rather than by +/// unpicking anything. +/// +/// `AddressRegistryDeployPinsChainTest` is what says whether this has been run +/// and worked. It fails until every supported network has the registry, which +/// is the state this repo is in right now. +contract Deploy is Script { + /// Deploys the suite named by `DEPLOYMENT_SUITE`. + /// + /// The suite is read before the key so that a mistyped `suite:` input fails + /// in seconds, naming what it should have been, rather than failing on a + /// missing `DEPLOYMENT_KEY` and sending the reader after the wrong thing. + function run() external { + bytes32 suite = keccak256(bytes(vm.envOr("DEPLOYMENT_SUITE", string("address-registry")))); + if (suite != DEPLOYMENT_SUITE_ADDRESS_REGISTRY) { + revert( + "Invalid deployment suite specified. Please set the DEPLOYMENT_SUITE environment variable to 'address-registry'." + ); + } + + uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY"); + + // `AddressRegistry` reads nothing and calls nothing, so it depends on + // no other deployment. The Zoltu factory itself is not a dependency + // here: `deployToNetworks` checks it on every network it actually + // deploys to. + address[] memory dependencies = new address[](0); + + LibRainDeploy.deployAndBroadcast( + vm, + LibRainDeploy.supportedNetworks(), + deployerPrivateKey, + type(AddressRegistry).creationCode, + "src/concrete/AddressRegistry.sol:AddressRegistry", + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + dependencies + ); + } +} From 7461a93d4afe00677bf596276da03803bd06b97c Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 13:49:04 +0000 Subject: [PATCH 09/29] fix(review): close the CodeRabbit findings that were real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of ten. The rest are pre-existing code, deliberate org convention, or the chain-red this PR exists to produce; they are answered on the threads and the still-valid ones are flagged in the PR body for a human. - `slither.config.json` matched the `src/abstract/RainDeployVerify` PREFIX, so a file added under it later would have been silently unanalyzed — including a deployable one. It matches the three filenames exactly now, which is what CLAUDE.md and the previous commit message already claimed it did. - `LibAddressRegistryDeploy`'s doc comment repeated half a sentence, from a scripted edit of mine that both inserted and kept it. - Two comments said the matrix forks "five" networks. The whole point of the abstract is that a network added to `supportedNetworks()` needs no edit, so prose that hardcodes the count contradicts the design it describes. - README now states that consumers need `forge-std` 1.16.1 remapped as `forge-std-1.16.1/`. The published package ships no `remappings.txt`, `soldeer.lock` or `dependencies/`, and everything in `src/` imports `Vm`, `console2` or `Test`. That was already true before this PR — `LibRainDeploy` has always imported forge-std — but nothing said so, and the verification abstracts widen the surface from `Vm` to `Test`. --- CLAUDE.md | 6 +++--- README.md | 16 ++++++++++++++++ slither.config.json | 2 +- src/lib/LibAddressRegistryDeploy.sol | 6 ++---- test/src/abstract/RainDeployVerifyChain.t.sol | 4 ++-- .../AddressRegistryDeployPinsChain.t.sol | 2 +- 6 files changed, 25 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fbb6911..0c2b939 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,9 +168,9 @@ These three are the only `src/` files `slither.config.json` filters out, by name. They are inherited by test contracts and never deployed, so slither's detectors — all of which are about deployed-code risk — have nothing to say about them except that an abstract does not implement its own virtuals and that -a cheatcode is called in a loop. The filter names the files rather than the -directory, so a future `src/abstract/` file that IS deployable is still -analyzed. +a cheatcode is called in a loop. The filter matches those three filenames +exactly, not the `src/abstract/` prefix, so a file added there later — including +a deployable one — is analyzed rather than silently exempted. The libraries are designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, diff --git a/README.md b/README.md index 7adc21c..c5403fc 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,22 @@ Via [soldeer](https://soldeer.xyz): forge soldeer install rain-deploy~ ``` +**You also need `forge-std` 1.16.1**, remapped as `forge-std-1.16.1/`. The +published package deliberately ships only `src/` and `script/` — no +`remappings.txt`, no `soldeer.lock`, no `dependencies/` — and everything under +`src/` here is Foundry test-and-script infrastructure that imports `Vm`, +`console2` or `Test`. So a consumer resolves `forge-std` itself: + +```toml +[dependencies] +forge-std = "1.16.1" +rain-deploy = "" +``` + +The version has to match: the import paths are version-qualified, which is +deliberate — it is what stops a consumer's incompatible `forge-std` from +silently satisfying these imports. + ## Develop This repo uses [nix](https://nixos.org/download.html). The default shell is the diff --git a/slither.config.json b/slither.config.json index 674d2ab..bad7e26 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-|src/abstract/RainDeployVerify", + "filter_paths": "dependencies/forge-std-|src/abstract/RainDeployVerify(Base|Chain|Offline)\\.sol", "detectors_to_exclude": "assembly,low-level-calls" } diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol index 9d66b73..32e12ca 100644 --- a/src/lib/LibAddressRegistryDeploy.sol +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -11,10 +11,8 @@ pragma solidity ^0.8.25; /// Both values are derived from the creation code this repo compiles, under /// this repo's own compiler settings, and are checked against it by /// `AddressRegistryDeployPinsOfflineTest` — the contract, the settings that -/// compile it -/// and the pins that describe it are all here, so there is no boundary across -/// and the pins that describe it are all here, so there is no boundary across -/// which they can silently diverge. +/// compile it and the pins that describe it are all here, so there is no +/// boundary across which they can silently diverge. /// /// The root authority is a constant in that creation code, so changing the root /// moves both values. diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 64aabaf..8e14baa 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -23,8 +23,8 @@ import { /// @title RainDeployVerifyChainTest /// @notice `RainDeployVerifyChain` inherited by a fixture repo whose versions /// are made live on every network by `setUp`, so the inherited -/// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks all -/// five supported networks and finds all three versions. +/// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks +/// every network `supportedNetworks()` returns and finds all three versions. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the /// fixture at some real deployment in another repo. A real one would make this diff --git a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol index 2deedbc..cff2ba3 100644 --- a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol +++ b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol @@ -12,7 +12,7 @@ import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeplo /// It is not. `AddressRegistry` has never been deployed and /// `ADDRESS_REGISTRY_ROOT` is still a placeholder, so this fails on the first /// network with `NotDeployedOnNetwork` and keeps failing until the contract is -/// deployed to all five. +/// deployed to every supported network. /// /// That failure is the check working. "Nothing is deployed at the address /// `LibAddressRegistry` reads" is true, it is the single most important fact From 58a2cc87d32a3d22e7625f0724df0243b8743b39 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 14:58:19 +0000 Subject: [PATCH 10/29] feat(suites): one declaration, deployed and verified, as a registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script/Deploy.sol` is now an abstract every deploy repo inherits, sharing ONE suite declaration with the verification abstracts. The property this buys: #26 previously declared what this repo deploys TWICE — once in a `test/` abstract for verification, once inside `Deploy.run()` for broadcasting — with nothing connecting them. The deploy script could have broadcast one contract while the tests verified another and stayed green. Now `AddressRegistryDeploySuites` is the only declaration and `script/Deploy.sol`, the offline test and the chain test all inherit it. The disagreement is not caught; it is unrepresentable. A registry the abstract iterates, not a single-suite `if`. `st0x.deploy`'s production script is TEN branches of identical shape restating their valid keys in a revert string nothing keeps in step with them. Here suites are an array: adding one is adding an entry, the keys a mistyped `DEPLOYMENT_SUITE` reports are built from that same array, and every suite is individually selectable — including a frozen release, which is how a snapshot from before a network existed reaches it. Keys are checked unique, on both paths that read them. Three things reading st0x.deploy changed, against the brief's premises: - The artifact path is NOT derivable. Six of its ten suites live under `src/concrete/deploy/` or `src/concrete/authorize/`, so it stays a field. - The network set is NOT `supportedNetworks()` everywhere. st0x bootstraps ONE chain per dispatch. `deployNetworks()` is virtual, defaulting to `supportedNetworks()`. - The recorded address and code hash STAY arguments. They are derivable, but `deployToNetworks` compares the recorded address against the creation code before it forks anything, precisely so a stale pin fails instead of deploying wherever the code lands. A derived value makes that derived-against-derived, and a guard comparing a value to itself is not a guard. `src/` for all of it, per the ruling: this repo's product IS the deployment process, so the machinery is not scaffolding that happens to live here. CLAUDE.md records that as a SCOPED exception a consumer repo must not copy, with the reason — there `src/` is the product and this is scaffolding around it. Also fixes a latent pre-existing fuzz flake: `testCheckResolvedAddressesUnreadableTargetReverts` assumed a code-less address answers nothing, but precompiles have no code and DO answer — the identity precompile echoes its calldata. A new fuzz seed hit it. --- CLAUDE.md | 83 +++++++-- README.md | 67 ++++--- script/Deploy.sol | 60 ++---- slither.config.json | 2 +- src/abstract/AddressRegistryDeploySuites.sol | 72 ++++++++ src/abstract/RainDeployBroadcast.sol | 93 ++++++++++ src/abstract/RainDeploySuitesBase.sol | 171 ++++++++++++++++++ src/abstract/RainDeployVerifyBase.sol | 146 ++++----------- src/abstract/RainDeployVerifyChain.sol | 36 ++-- src/abstract/RainDeployVerifyOffline.sol | 69 +++---- .../AddressRegistryDeployVersions.sol | 56 ------ ...eployVersions.sol => MockDeploySuites.sol} | 47 +++-- test/concrete/MockBroadcastDeploy.sol | 35 ++++ test/concrete/MockDuplicateSuites.sol | 56 ++++++ test/src/abstract/RainDeployBroadcast.t.sol | 94 ++++++++++ test/src/abstract/RainDeploySuitesBase.t.sol | 121 +++++++++++++ test/src/abstract/RainDeployVerifyChain.t.sol | 35 ++-- .../abstract/RainDeployVerifyOffline.t.sol | 104 ++++++----- .../AddressRegistryDeployPinsChain.t.sol | 4 +- .../AddressRegistryDeployPinsOffline.t.sol | 6 +- test/src/lib/LibRainDeploy.t.sol | 7 + 21 files changed, 971 insertions(+), 393 deletions(-) create mode 100644 src/abstract/AddressRegistryDeploySuites.sol create mode 100644 src/abstract/RainDeployBroadcast.sol create mode 100644 src/abstract/RainDeploySuitesBase.sol delete mode 100644 test/abstract/AddressRegistryDeployVersions.sol rename test/abstract/{MockDeployVersions.sol => MockDeploySuites.sol} (65%) create mode 100644 test/concrete/MockBroadcastDeploy.sol create mode 100644 test/concrete/MockDuplicateSuites.sol create mode 100644 test/src/abstract/RainDeployBroadcast.t.sol create mode 100644 test/src/abstract/RainDeploySuitesBase.t.sol diff --git a/CLAUDE.md b/CLAUDE.md index 0c2b939..1978f68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,20 +116,65 @@ address, verifying its code hash first, exactly as `LibRainDeploy` verifies `ZOLTU_FACTORY_CODEHASH`. It resolves a name to an address and nothing more: what a consumer resolves a name for, and when, is the consumer's business. -**`script/Deploy.sol`** — the broadcast. Deploys `AddressRegistry` to every -network in `supportedNetworks()` at the address `LibAddressRegistryDeploy` pins, -dispatching on `DEPLOYMENT_SUITE` (`address-registry`) and reverting on anything -else. Run only via the `Manual sol artifacts` workflow. +### `src/` holds the deploy machinery here. That is a SCOPED EXCEPTION. + +`src/abstract/RainDeploy*.sol` are test and script infrastructure, and they live +in `src/` rather than `test/`. Two reasons, and the second is the one that +matters: + +1. `.soldeerignore` excludes `test/` from the published package, and a + downstream repo has to import all of this — its `script/Deploy.sol` inherits + `RainDeployBroadcast`, its test contracts inherit `RainDeployVerify*`. An + abstract in a path the package excludes is unusable by every consumer. +2. **This repo's PRODUCT is the deployment process.** Machinery for deploying + and for verifying deployments is not scaffolding that happens to live here — + it is the thing the package exists to publish. So `src/` is where it belongs. + +**Do not copy this into a consumer repo.** There, `src/` is the product — +tokens, vaults, a factory — and deploy verification is scaffolding around it, so +the usual convention stands unchanged: `test/src/**` mirrors `src/**`, and test +abstracts live under `test/`. The exception is earned by what this repo IS, and +a repo that merely USES this machinery has not earned it. + +The exception is scoped to the deploy/verify abstracts and the suite +declaration. `src/concrete/AddressRegistry.sol` is an ordinary deployed +contract, tested from `test/src/concrete/` exactly as the convention requires. + +**`src/abstract/RainDeploySuitesBase.sol`** — the ONE declaration of what a repo +deploys: per suite, a key, the creation code, the recorded address/code +hash/runtime code, an artifact path, and dependencies. + +Both sides read it. `RainDeployBroadcast` deploys from it and +`RainDeployVerify*` verify against it, so "the deploy script broadcasts one +contract while the tests verify another" is not a statement that can be true — +not because something checks for it, but because there is one array and all +three contracts read it. + +Suites are a REGISTRY the abstract iterates, not a chain of `else if`. A repo +adds a suite by adding an array entry; the keys reported by a mistyped +`DEPLOYMENT_SUITE` are built from that same array, so the failure message cannot +fall behind the suites it describes. Keys are checked unique, because the key is +what selects what gets broadcast. + +**`src/abstract/RainDeployBroadcast.sol`** — the broadcast. Selects one suite by +`DEPLOYMENT_SUITE` and deploys it, before reading `DEPLOYMENT_KEY` so a mistyped +suite fails naming the valid ones rather than on a missing key. +`deployNetworks()` defaults to `supportedNetworks()` and is overridable for +repos that bootstrap one chain per dispatch. + +**`script/Deploy.sol`** — +`contract Deploy is AddressRegistryDeploySuites, +RainDeployBroadcast {}`. Empty +on purpose: the suites and the broadcast are both inherited. Run only via the +`Manual sol artifacts` workflow. + +**`src/abstract/AddressRegistryDeploySuites.sol`** — this repo's own +declaration, inherited by `script/Deploy.sol` and by both pins test contracts. **`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every -deploy repo inherits instead of hand-writing. In `src/`, not `test/`, because -`.soldeerignore` excludes `/test` from the published package and a consumer that -cannot import it cannot use it. +deploy repo inherits instead of hand-writing. -A repo declares its versions once — `releasedVersions()` and -`candidateVersion()` on one abstract contract — and inherits that into one -`RainDeployVerifyOffline` and one `RainDeployVerifyChain`. Nothing is per -version and nothing is per network. +Nothing is per suite beyond an array entry, and nothing anywhere is per network. The creation code is the only parameter. The Zoltu factory is `CREATE2` over its calldata under a zero salt, so the address is a pure function of it, and running @@ -164,13 +209,15 @@ the same on every network, so a constructor reading `block.chainid` or similar is a DEFECT: it fails hard, naming the chain and both hashes. There is deliberately no per-chain code hash to record. -These three are the only `src/` files `slither.config.json` filters out, by -name. They are inherited by test contracts and never deployed, so slither's -detectors — all of which are about deployed-code risk — have nothing to say -about them except that an abstract does not implement its own virtuals and that -a cheatcode is called in a loop. The filter matches those three filenames -exactly, not the `src/abstract/` prefix, so a file added there later — including -a deployable one — is analyzed rather than silently exempted. +The `src/abstract/` files are the only `src/` files `slither.config.json` +filters out, by name. They are inherited by test contracts and never deployed, +so slither's detectors — all of which are about deployed-code risk — have +nothing to say about them except that an abstract does not implement its own +virtuals and that a cheatcode is called in a loop, and — because slither skips +`test/` and `script/` — that an abstract's virtuals have no caller. The filter +matches those filenames exactly, not the `src/abstract/` prefix, so a file added +there later — including a deployable one — is analyzed rather than silently +exempted. The libraries are designed to be called from Foundry scripts (`forge script`) in consuming repos, not directly. Consuming repos provide their own creation code, diff --git a/README.md b/README.md index c5403fc..4173ab7 100644 --- a/README.md +++ b/README.md @@ -37,35 +37,58 @@ Approach: than assertions hand-enumerated per version and per chain in every deploy repo. -## Deploy verification - -A deploy repo records, per released version, a deterministic address, a code -hash and the bytecode behind them. `src/abstract/RainDeployVerify*.sol` is the -verification of those records, inherited rather than rewritten. - -**The creation code is the only parameter.** The Zoltu factory is `CREATE2` over -its calldata under a zero salt, so the address is a pure function of the -creation code and identical on every network, and running that creation code -once locally yields the runtime code and its hash. Everything else a pointers -file holds is a checked output. +## One declaration, deployed and verified -A repo declares its versions once and inherits that declaration into one offline -contract and one chain contract: +A repo declares its suites ONCE. A suite is a named snapshot: a key, the +creation code, the recorded address/code hash/runtime code, the artifact path +and the addresses that must already be on chain before it can be deployed. ```solidity -abstract contract MyDeployVersions is RainDeployVerifyBase { - function releasedVersions() internal pure override returns (DeployVersion[] memory) { /* frozen snapshots */ } - function candidateVersion() internal pure override returns (DeployCandidate memory) { /* current source */ } +// src/abstract/MyDeploySuites.sol +abstract contract MyDeploySuites is RainDeploySuitesBase { + function releasedSuites() internal pure override returns (DeploySuite[] memory); + function candidateSuite() internal pure override returns (DeployCandidate memory); } -contract MyDeployPinsOfflineTest is MyDeployVersions, RainDeployVerifyOffline {} -contract MyDeployPinsChainTest is MyDeployVersions, RainDeployVerifyChain {} +// script/Deploy.sol +contract Deploy is MyDeploySuites, RainDeployBroadcast {} + +// test/src/concrete/MyDeployPinsOffline.t.sol +contract MyDeployPinsOfflineTest is MyDeploySuites, RainDeployVerifyOffline {} + +// test/src/concrete/MyDeployPinsChain.t.sol +contract MyDeployPinsChainTest is MyDeploySuites, RainDeployVerifyChain {} ``` -There is nothing per version and nothing per network. A new release adds an -array entry; a network added to `supportedNetworks()` is checked for every -version already recorded, which is exactly the cell a hand-written suite never -grows. +The broadcast and the verification read the SAME array. "The deploy script ships +one contract while the tests verify another" is therefore not a statement that +can be true — not because something checks for it, but because there is nothing +for it to disagree with. A repo that wrote its suites out twice would have that +bug available to it; this one does not. + +Suites are a **registry the abstract iterates**, not a chain of `else if`. +Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports +the valid keys built from that same array, so the error cannot fall behind the +suites it describes, and keys are checked unique because the key is what selects +what gets broadcast. + +Every suite is individually selectable, including a frozen release — which is +how a snapshot from before a network existed reaches that network. + +## Deploy verification + +**The creation code is the only input.** The Zoltu factory is `CREATE2` over its +calldata under a zero salt, so the address is a pure function of the creation +code and identical on every network, and running that creation code once locally +yields the runtime code and its hash. Everything else a suite records is a +checked output. + +Recorded rather than derived, deliberately. `LibRainDeploy` compares the +recorded address against the creation code **before it forks anything**, so a +stale pin fails instead of deploying to wherever the code happens to land. +Deriving the pins at broadcast time would make that comparison +derived-against-derived, and a guard that compares a value to itself is not a +guard. Three groups, sorted by what each is anchored to and therefore by what each can catch: diff --git a/script/Deploy.sol b/script/Deploy.sol index 7cd0fd5..7a500cb 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -2,21 +2,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Script} from "forge-std-1.16.1/src/Script.sol"; - -import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; -import {LibAddressRegistryDeploy} from "../src/lib/LibAddressRegistryDeploy.sol"; -import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; - -/// @dev Hash of the "address-registry" deployment suite string. MUST match the -/// `suite:` input of `.github/workflows/manual-sol-artifacts.yaml`, which is -/// what supplies `DEPLOYMENT_SUITE`. -bytes32 constant DEPLOYMENT_SUITE_ADDRESS_REGISTRY = keccak256("address-registry"); +import {RainDeployBroadcast} from "../src/abstract/RainDeployBroadcast.sol"; +import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploySuites.sol"; /// @title Deploy -/// @notice Broadcasts `AddressRegistry` to every network in -/// `LibRainDeploy.supportedNetworks()`, at the deterministic address -/// `LibAddressRegistryDeploy` pins. +/// @notice The on-chain deploy. Broadcasts whichever suite `DEPLOYMENT_SUITE` +/// names, through the Zoltu factory, to every supported network. +/// +/// Empty on purpose. The suites come from `AddressRegistryDeploySuites`, which +/// is the same declaration the verification tests inherit, and the dispatch, +/// the key handling and the broadcast come from `RainDeployBroadcast`. A deploy +/// repo writes its declaration and this pair of base contracts, and nothing +/// else — no per-suite branch, no per-network list. /// /// This is the missing half of the deploy lifecycle `package-release.yaml` /// assumes: `rainix-tag-release` verifies and publishes pins for a deployment @@ -25,7 +22,7 @@ bytes32 constant DEPLOYMENT_SUITE_ADDRESS_REGISTRY = keccak256("address-registry /// workflow, before tagging. /// /// Deploying is idempotent by construction. `deployToNetworks` checks the -/// derived address against the creation code before it forks anything, then +/// recorded address against the creation code before it forks anything, then /// skips any network that already has code there, so a partial run — three /// chains of five, one RPC down — is fixed by running it again rather than by /// unpicking anything. @@ -33,37 +30,4 @@ bytes32 constant DEPLOYMENT_SUITE_ADDRESS_REGISTRY = keccak256("address-registry /// `AddressRegistryDeployPinsChainTest` is what says whether this has been run /// and worked. It fails until every supported network has the registry, which /// is the state this repo is in right now. -contract Deploy is Script { - /// Deploys the suite named by `DEPLOYMENT_SUITE`. - /// - /// The suite is read before the key so that a mistyped `suite:` input fails - /// in seconds, naming what it should have been, rather than failing on a - /// missing `DEPLOYMENT_KEY` and sending the reader after the wrong thing. - function run() external { - bytes32 suite = keccak256(bytes(vm.envOr("DEPLOYMENT_SUITE", string("address-registry")))); - if (suite != DEPLOYMENT_SUITE_ADDRESS_REGISTRY) { - revert( - "Invalid deployment suite specified. Please set the DEPLOYMENT_SUITE environment variable to 'address-registry'." - ); - } - - uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY"); - - // `AddressRegistry` reads nothing and calls nothing, so it depends on - // no other deployment. The Zoltu factory itself is not a dependency - // here: `deployToNetworks` checks it on every network it actually - // deploys to. - address[] memory dependencies = new address[](0); - - LibRainDeploy.deployAndBroadcast( - vm, - LibRainDeploy.supportedNetworks(), - deployerPrivateKey, - type(AddressRegistry).creationCode, - "src/concrete/AddressRegistry.sol:AddressRegistry", - LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, - LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, - dependencies - ); - } -} +contract Deploy is AddressRegistryDeploySuites, RainDeployBroadcast {} diff --git a/slither.config.json b/slither.config.json index bad7e26..ffcc6c9 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-|src/abstract/RainDeployVerify(Base|Chain|Offline)\\.sol", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifyOffline)|AddressRegistryDeploySuites)\\.sol", "detectors_to_exclude": "assembly,low-level-calls" } diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol new file mode 100644 index 0000000..74b1ef2 --- /dev/null +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../concrete/AddressRegistry.sol"; +import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; + +/// @title AddressRegistryDeploySuites +/// @notice Everything this repo deploys, declared ONCE. +/// +/// Three contracts inherit this and nothing else declares a suite: +/// +/// - `script/Deploy.sol` broadcasts from it +/// - `AddressRegistryDeployPinsOfflineTest` checks its records against its +/// creation code +/// - `AddressRegistryDeployPinsChainTest` checks it against every chain +/// +/// So "the deploy script broadcasts one contract while the tests verify +/// another" is not a thing that can be true here. Not because something checks +/// for it — because there is only one list, and all three read it. +/// +/// It lives in `src/` rather than `test/` for two reasons. `.soldeerignore` +/// excludes `test/`, and a downstream `script/` has to import its own +/// equivalent. And in THIS repo the deployment process is the product: see the +/// scoped exception recorded in `CLAUDE.md`. +/// +/// This declaration is the whole of what a deploy repo writes. The derivation, +/// the comparisons, the source anchor, the networks matrix and the broadcast +/// are all inherited. There is deliberately nothing per suite beyond an array +/// entry and nothing per network at all. +/// +/// From the first `sol-v*` release this is what `script/Build.sol` generates. +abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + /// @dev Empty: no release has been cut, so no snapshot is frozen. + /// `src/generated//` is append-only and `ADDRESS_REGISTRY_ROOT` is + /// still a placeholder, so a snapshot written now could never be corrected. + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + return new DeploySuite[](0); + } + + /// @inheritdoc RainDeploySuitesBase + /// @dev The pins in `LibAddressRegistryDeploy` are hand-written literals, + /// and they are what the internal group checks the derivation against and + /// what the broadcast asserts against before it forks anything. + /// + /// The creation code and runtime code come from source because nothing + /// records them yet — there is no `src/generated/candidate/` pointers file + /// to read them from. Until there is, the source anchor compares source + /// against itself and can only pass: what it protects against is a RECORDED + /// creation code drifting from the source it claims to be, and there is no + /// recorded creation code here to drift. The address and code hash pins ARE + /// recorded, and those are checked for real. + /// + /// `AddressRegistry` reads nothing and calls nothing at construction, so it + /// has no dependency that must already be on chain. + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "address-registry", + creationCode: type(AddressRegistry).creationCode, + storedDeployedAddress: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, + storedRuntimeCode: type(AddressRegistry).runtimeCode, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); + } +} diff --git a/src/abstract/RainDeployBroadcast.sol b/src/abstract/RainDeployBroadcast.sol new file mode 100644 index 0000000..0a2ed25 --- /dev/null +++ b/src/abstract/RainDeployBroadcast.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; + +import {DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; + +/// @title RainDeployBroadcast +/// @notice The broadcast, inherited rather than rewritten per repo. Selects one +/// suite by `DEPLOYMENT_SUITE` and deploys it through the Zoltu factory. +/// +/// A deploy repo's whole script becomes the declaration plus this: +/// +/// ```solidity +/// contract Deploy is MyDeploySuites, RainDeployBroadcast {} +/// ``` +/// +/// The declaration it inherits is the SAME one its verification contracts +/// inherit. That is the point of putting it here: a repo that wrote its suites +/// out twice could broadcast one contract while its tests verified another and +/// stay green, because nothing would connect the two lists. There is one list. +/// +/// ## A registry, not a branch +/// +/// One suite is a degenerate case. `st0x.deploy` broadcasts ten, and its script +/// is ten `else if` arms of identical shape differing only in their arguments, +/// with the valid keys restated in a revert string that nothing keeps in step +/// with the arms. Here the keys and the arms are the same array, so adding a +/// suite is adding an entry and the failure message follows from the registry +/// rather than from a string somebody remembered to update. +/// +/// ## What is NOT derived, and why +/// +/// The recorded address and code hash are passed to `deployAndBroadcast`, not +/// derived from the creation code here. They are derivable — that is exactly +/// what `RainDeployVerifyOffline` derives them for — but deriving them at +/// broadcast time would defeat the check that matters most at broadcast time. +/// `LibRainDeploy.deployToNetworks` compares the recorded address against the +/// address the creation code derives BEFORE it forks anything, precisely so a +/// stale pin fails instead of silently deploying somewhere the repo's constants +/// do not describe. Feeding it a derived value would make that comparison +/// derived-against-derived, and a guard that compares a value to itself is not +/// a guard. +/// +/// The artifact path is declared too. `src/concrete/.sol:` holds +/// only for the flattest repos; `st0x.deploy` groups concretes under +/// `deploy/` and `authorize/`, so a convention would be wrong for most of its +/// suites. +abstract contract RainDeployBroadcast is RainDeploySuitesBase, Script { + /// The networks to broadcast to. Every supported network by default, which + /// is what a deterministic deployment usually wants: one address, every + /// chain, in one dispatch. + /// + /// Overridable because that is not universal. A repo bootstrapping onto one + /// chain at a time — `st0x.deploy` selects between Ethereum and HyperEVM + /// per dispatch — returns a single-element list from its own env var + /// instead. The reusable workflow already carries a `network:` input for + /// exactly this. + /// @return The network names to deploy to, as `[rpc_endpoints]` aliases. + function deployNetworks() internal view virtual returns (string[] memory) { + return LibRainDeploy.supportedNetworks(); + } + + /// Broadcasts the suite `DEPLOYMENT_SUITE` names. + /// + /// The suite is resolved before the key is read, so a mistyped suite fails + /// in seconds listing the valid ones rather than failing on a missing + /// `DEPLOYMENT_KEY` and sending the reader after the wrong thing. + /// + /// One suite per run, deliberately. Deploying through a shared factory in a + /// single run couples every deployment to the others' success, and a suite + /// that depends on another needs that other to be on chain first — which + /// `dependencies` enforces per network, and which a caller satisfies by + /// dispatching in order. + function run() external { + DeploySuite memory suite = suiteByName(vm.envOr("DEPLOYMENT_SUITE", string(""))); + + uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY"); + + LibRainDeploy.deployAndBroadcast( + vm, + deployNetworks(), + deployerPrivateKey, + suite.creationCode, + suite.artifactPath, + suite.storedDeployedAddress, + suite.storedBytecodeHash, + suite.dependencies + ); + } +} diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol new file mode 100644 index 0000000..44bc2fe --- /dev/null +++ b/src/abstract/RainDeploySuitesBase.sol @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// Thrown when two suites share a key. The key selects what gets broadcast, so +/// a duplicate makes the selection ambiguous and one of the two unreachable. +/// @param suite The key declared more than once. +error DuplicateDeploySuite(string suite); + +/// Thrown when `DEPLOYMENT_SUITE` names no declared suite. Carries the valid +/// keys, because the whole point of a registry is that the answer is not one +/// hardcoded string the caller has to already know. +/// @param requested The key that was asked for. +/// @param validSuites The declared keys, comma separated. +error UnknownDeploymentSuite(string requested, string validSuites); + +/// One deployable unit: a named snapshot of one contract. +/// +/// `creationCode` is the ONLY input. The Zoltu factory is `CREATE2` over its +/// calldata under a zero salt, so the deploy address is a pure function of it +/// and identical on every network, and running it once locally yields the +/// runtime code and its hash. The address, code hash and runtime code recorded +/// beside it are checked OUTPUTS, which is what the verification abstracts +/// check them as. +/// +/// They are recorded rather than derived on purpose. `LibRainDeploy` compares +/// the recorded address against the creation code before it forks anything, so +/// a snapshot whose pins have gone stale fails BEFORE broadcasting rather than +/// deploying to wherever the code happens to land. Deriving them here would +/// make that comparison derived-against-derived, and a guard that compares a +/// value to itself is not a guard. +struct DeploySuite { + /// The key. Unique across every suite a repo declares: it is what + /// `DEPLOYMENT_SUITE` selects for broadcasting, and the label every + /// verification error names. + /// + /// A repo with one contract and several frozen releases gives each release + /// its own key, because each is separately deployable — a chain added after + /// a release is exactly the case where an OLD snapshot has to be broadcast + /// on its own. + string suite; + /// The creation code this suite is a snapshot of. The only parameter. + /// + /// A frozen `CREATION_CODE` constant for a released snapshot, or + /// `type(X).creationCode` where nothing is frozen yet. Frozen matters: a + /// released suite broadcasts the exact bytes its audit covered, whatever + /// the current source now compiles to. + bytes creationCode; + /// The deploy address recorded for this suite. + address storedDeployedAddress; + /// The deployed code hash recorded for this suite. + bytes32 storedBytecodeHash; + /// The runtime code recorded for this suite. A frozen `RUNTIME_CODE` + /// constant, or `type(X).runtimeCode` where nothing is frozen yet. + bytes storedRuntimeCode; + /// `:`, for the explorer verification command. + /// + /// Declared rather than derived from the contract name. `src/concrete/` + /// holds only the flattest repos; a repo that groups concretes into + /// subdirectories has paths no naming convention recovers. + string artifactPath; + /// Addresses that MUST already have code on a network before this suite is + /// broadcast there. Ordinarily other suites' recorded addresses: a + /// constructor that bakes in a beacon, or a fallback that delegatecalls a + /// facet, silently produces a broken deployment if its target is absent. + address[] dependencies; +} + +/// The rolling candidate: the snapshot that tracks current source rather than a +/// frozen release, paired with the current source's creation code it MUST +/// equal. +/// +/// This pairing is the ONLY thing that catches a snapshot of the wrong +/// contract. Every check internal to a snapshot is satisfied by a consistent +/// snapshot of the wrong thing, so without an anchor to source there is nothing +/// that says the recorded bytes belong to the contract this repo compiles. +/// +/// It is deliberately absent from `DeploySuite` and therefore from released +/// suites: a released tag is MEANT to diverge from current source, so anchoring +/// one to source would fail on every release that is not the newest. That is a +/// property of the assertion, not an opt-out — there is no way for a caller to +/// spell "released, and also skip the checks that do apply". +struct DeployCandidate { + /// The candidate's own recorded snapshot, checked exactly as any other. + DeploySuite snapshot; + /// `type(X).creationCode` for the contract the candidate claims to be. + bytes sourceCreationCode; +} + +/// @title RainDeploySuitesBase +/// @notice The ONE declaration of what a repo deploys, consumed by both the +/// broadcasting abstract and the verification abstracts. +/// +/// That sharing is the point. A repo that declared its suites twice — once for +/// the deploy script, once for the verification tests — could broadcast one +/// contract while verifying another and have every test pass, because nothing +/// would connect the two lists. Here there is one list, so the deployment and +/// the thing checked against the chain cannot disagree: not because it is +/// checked, but because there is nothing to disagree with. +/// +/// A repo overrides `releasedSuites` and `candidateSuite` on one abstract +/// contract and inherits that into its deploy script and its test contracts. +/// Nothing else is per suite, and nothing anywhere is per network. +abstract contract RainDeploySuitesBase { + /// Every FROZEN released suite, in any order. A released snapshot is + /// immutable: its recorded bytes describe a deployment that already + /// happened, so it is never regenerated and never anchored to current + /// source. + /// @return The released suites. + function releasedSuites() internal pure virtual returns (DeploySuite[] memory); + + /// The rolling candidate — the snapshot describing what this repo compiles + /// right now. Required rather than optional: a deploy repo always compiles + /// a current source, so there is always something for the source-anchored + /// check to anchor to, and making it optional would let the only check that + /// catches a wrong-contract snapshot be silently skipped. + /// @return The candidate. + function candidateSuite() internal pure virtual returns (DeployCandidate memory); + + /// Every suite this repo declares: the released ones followed by the + /// candidate. This is the verification set and the deploy registry, which + /// are the same set because they are the same declaration. + /// + /// Keys are checked unique here rather than anywhere more specific, so both + /// sides pay for the check and neither can be handed an ambiguous registry. + /// @return suites Every declared suite. + function allSuites() internal pure returns (DeploySuite[] memory suites) { + DeploySuite[] memory released = releasedSuites(); + suites = new DeploySuite[](released.length + 1); + for (uint256 i = 0; i < released.length; i++) { + suites[i] = released[i]; + } + suites[released.length] = candidateSuite().snapshot; + + for (uint256 i = 0; i < suites.length; i++) { + for (uint256 j = i + 1; j < suites.length; j++) { + if (keccak256(bytes(suites[i].suite)) == keccak256(bytes(suites[j].suite))) { + revert DuplicateDeploySuite(suites[i].suite); + } + } + } + } + + /// Every declared key, comma separated, for the unknown-suite error. + /// @return names The declared keys. + function suiteNames() internal pure returns (string memory names) { + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + names = i == 0 ? suites[i].suite : string.concat(names, ", ", suites[i].suite); + } + } + + /// The suite a key selects. + /// + /// Iterating the registry rather than branching on a hash: a repo adds a + /// suite by adding an array entry, and the set of valid keys the failure + /// reports follows from the same array rather than from a string somebody + /// remembered to update. + /// @param requested The key to select, from `DEPLOYMENT_SUITE`. + /// @return The selected suite. + function suiteByName(string memory requested) internal pure returns (DeploySuite memory) { + DeploySuite[] memory suites = allSuites(); + bytes32 requestedHash = keccak256(bytes(requested)); + for (uint256 i = 0; i < suites.length; i++) { + if (keccak256(bytes(suites[i].suite)) == requestedHash) { + return suites[i]; + } + } + revert UnknownDeploymentSuite(requested, suiteNames()); + } +} diff --git a/src/abstract/RainDeployVerifyBase.sol b/src/abstract/RainDeployVerifyBase.sol index 5865571..267a5fc 100644 --- a/src/abstract/RainDeployVerifyBase.sol +++ b/src/abstract/RainDeployVerifyBase.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; /// Thrown when the pure `LibRainDeploy.zoltuAddress` formula and an actual @@ -11,68 +12,17 @@ import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; /// creation code lands. Both are `LibRainDeploy`'s, so this is a defect in the /// library rather than in any snapshot, and it invalidates every derivation /// made from it. -/// @param version The version label whose creation code was being derived. +/// @param suite The suite whose creation code was being derived. /// @param formulaAddress The address `zoltuAddress` computed. /// @param factoryAddress The address the factory bytecode actually deployed to. -error ZoltuDerivationMismatch(string version, address formulaAddress, address factoryAddress); +error ZoltuDerivationMismatch(string suite, address formulaAddress, address factoryAddress); -/// One recorded deployment of one version of one contract. -/// -/// `creationCode` is the ONLY input. Everything else is an OUTPUT that gets -/// checked against it: the Zoltu factory is `CREATE2` over its calldata under a -/// zero salt, so the deploy address is a pure function of the creation code and -/// identical on every network, and running that creation code once locally -/// yields the runtime code and its hash. A pointers file records all four, but -/// only one of them is a parameter. -/// -/// `creationCode` comes from wherever this version's creation code is recorded: -/// the frozen `CREATION_CODE` constant of a released snapshot, or -/// `type(X).creationCode` for a version that has no frozen snapshot yet. -struct DeployVersion { - /// The version label, e.g. `0_1_5` or `candidate`. Carried into every error - /// so a failure names the version that failed rather than an array index. - string version; - /// The creation code this version is a snapshot of. The only parameter. - bytes creationCode; - /// The deploy address recorded for this version, to be checked against the - /// address `creationCode` derives. - address storedDeployedAddress; - /// The deployed code hash recorded for this version, to be checked against - /// the hash `creationCode` produces. - bytes32 storedBytecodeHash; - /// The runtime code recorded for this version, to be checked against - /// `storedBytecodeHash`. A frozen `RUNTIME_CODE` constant for a released - /// snapshot, or `type(X).runtimeCode` where nothing is frozen yet. - bytes storedRuntimeCode; -} - -/// The rolling candidate: the snapshot that tracks current source rather than a -/// frozen release, paired with the current source's creation code it MUST -/// equal. -/// -/// This pairing is the ONLY thing that catches a snapshot of the wrong -/// contract. Every check internal to a snapshot is satisfied by a consistent -/// snapshot of the wrong thing, so without an anchor to source there is nothing -/// that says the recorded bytes belong to the contract this repo compiles. -/// -/// It is deliberately absent from `DeployVersion` and therefore from released -/// versions: a released tag is MEANT to diverge from current source, so -/// anchoring one to source would fail on every release that is not the newest. -/// That is a property of the assertion, not an opt-out — there is no way for a -/// caller to spell "released, and also skip the checks that do apply". -struct DeployCandidate { - /// The candidate's own recorded snapshot, checked exactly as any other. - DeployVersion snapshot; - /// `type(X).creationCode` for the contract the candidate claims to be. - bytes sourceCreationCode; -} - -/// What a version's creation code derives, offline and by itself. Computed +/// What a suite's creation code derives, offline and by itself. Computed /// once and then compared against whatever claims to hold it, whether that is a /// recorded constant or a live chain. struct DerivedDeploy { - /// The version label the derivation came from. - string version; + /// The suite the derivation came from. + string suite; /// The address the creation code deploys to, on every network. address deployedAddress; /// The code hash the creation code leaves behind at that address. @@ -80,22 +30,25 @@ struct DerivedDeploy { } /// @title RainDeployVerifyBase -/// @notice The parameterization shared by every deploy-verification group: a -/// repo declares its versions once, and the derivation from creation code to -/// (address, code hash) happens in one place rather than being restated per -/// version and per chain. +/// @notice The derivation every deploy-verification group shares: from a +/// suite's creation code to the (address, code hash) it produces, in one place +/// rather than restated per suite and per chain. +/// +/// The suites themselves come from `RainDeploySuitesBase`, which is the SAME +/// declaration `RainDeployBroadcast` deploys from. Verification and deployment +/// therefore cannot describe different things. /// /// This is not inherited directly. `RainDeployVerifyOffline` and /// `RainDeployVerifyChain` each inherit it and contribute the checks that need -/// no network and the checks that do, respectively. A repo declares its -/// versions on one abstract contract and inherits that into one of each, so -/// running the offline checks never touches an RPC endpoint — an outage is then -/// a failure of one contract that plainly is about the chain, and can never be -/// confused with, or take down, the assertions that hold offline. +/// no network and the checks that do, respectively. A repo inherits its +/// declaration into one of each, so running the offline checks never touches an +/// RPC endpoint — an outage is then a failure of one contract that plainly is +/// about the chain, and can never be confused with, or take down, the +/// assertions that hold offline. /// /// ## Chain-independent runtime code is a requirement, not a caveat /// -/// A single `storedBytecodeHash` per version can only be true if the runtime +/// A single `storedBytecodeHash` per suite can only be true if the runtime /// code is the same on every network. A constructor that reads `block.chainid`, /// or anything else that varies per chain, produces a different code hash per /// chain and cannot be described by these snapshots at all. Deploying through @@ -103,36 +56,8 @@ struct DerivedDeploy { /// spends it. So a per-chain code hash difference is a DEFECT in the contract, /// reported as a hard failure naming the chain and both hashes, and there is /// deliberately no per-chain code hash to record. -abstract contract RainDeployVerifyBase is Test { - /// Every FROZEN released version, in any order. A released snapshot is - /// immutable: its recorded bytes describe a deployment that already - /// happened, so it is never regenerated and never anchored to current - /// source. - /// @return The released versions to verify. - function releasedVersions() internal pure virtual returns (DeployVersion[] memory); - - /// The rolling candidate — the snapshot describing what this repo compiles - /// right now. Required rather than optional: a deploy repo always compiles - /// a current source, so there is always something for the source-anchored - /// check to anchor to, and making it optional would let the only check that - /// catches a wrong-contract snapshot be silently skipped. - /// @return The candidate to verify. - function candidateVersion() internal pure virtual returns (DeployCandidate memory); - - /// Every version this repo records: the released ones plus the candidate. - /// The checks that apply to a version regardless of its status run over - /// this. - /// @return versions The released versions followed by the candidate. - function allVersions() internal pure returns (DeployVersion[] memory versions) { - DeployVersion[] memory released = releasedVersions(); - versions = new DeployVersion[](released.length + 1); - for (uint256 i = 0; i < released.length; i++) { - versions[i] = released[i]; - } - versions[released.length] = candidateVersion().snapshot; - } - - /// Derives what a version's creation code deploys to, from the creation +abstract contract RainDeployVerifyBase is RainDeploySuitesBase, Test { + /// Derives what a suite's creation code deploys to, from the creation /// code alone. /// /// The address comes from the pure `LibRainDeploy.zoltuAddress` formula and @@ -146,7 +71,7 @@ abstract contract RainDeployVerifyBase is Test { /// clears the derived address first, so that it reads ONLY what the /// creation code produces. Both matter: /// - /// - Two versions can legitimately share creation code (a release that + /// - Two suites can legitimately share creation code (a release that /// changed nothing that compiles), and `CREATE2` to an occupied address /// fails. Clearing makes the second derivation work, and reverting means /// the first never occupied it in the first place. @@ -154,10 +79,10 @@ abstract contract RainDeployVerifyBase is Test { /// locally deployed contract that leaked into a fork would be compared /// against itself, and every chain would pass whether or not anything is /// deployed there. - /// @param version The version to derive from. + /// @param suite The suite to derive from. /// @return derived The address and code hash the creation code produces. - function deriveDeployment(DeployVersion memory version) internal returns (DerivedDeploy memory derived) { - address formulaAddress = LibRainDeploy.zoltuAddress(version.creationCode); + function deriveDeployment(DeploySuite memory suite) internal returns (DerivedDeploy memory derived) { + address formulaAddress = LibRainDeploy.zoltuAddress(suite.creationCode); uint256 snapshotId = vm.snapshotState(); @@ -168,14 +93,13 @@ abstract contract RainDeployVerifyBase is Test { vm.resetNonce(formulaAddress); LibRainDeploy.etchZoltuFactory(vm); - address factoryAddress = LibRainDeploy.deployZoltu(version.creationCode); + address factoryAddress = LibRainDeploy.deployZoltu(suite.creationCode); if (factoryAddress != formulaAddress) { - revert ZoltuDerivationMismatch(version.version, formulaAddress, factoryAddress); + revert ZoltuDerivationMismatch(suite.suite, formulaAddress, factoryAddress); } - derived = DerivedDeploy({ - version: version.version, deployedAddress: formulaAddress, bytecodeHash: factoryAddress.codehash - }); + derived = + DerivedDeploy({suite: suite.suite, deployedAddress: formulaAddress, bytecodeHash: factoryAddress.codehash}); // revertToState returns whether the snapshot existed; it was taken // above, so bind and reference it to satisfy the unused-return lint @@ -184,16 +108,16 @@ abstract contract RainDeployVerifyBase is Test { (reverted); } - /// Derives every version once, before anything forks. Callers that compare + /// Derives every suite once, before anything forks. Callers that compare /// against chains need the derivation to have already happened on a local /// EVM, because on a fork the derived address is exactly the address the /// deployment under test occupies. - /// @param versions The versions to derive. + /// @param suites The suites to derive. /// @return derived The derivation of each, positionally paired. - function deriveDeployments(DeployVersion[] memory versions) internal returns (DerivedDeploy[] memory derived) { - derived = new DerivedDeploy[](versions.length); - for (uint256 i = 0; i < versions.length; i++) { - derived[i] = deriveDeployment(versions[i]); + function deriveDeployments(DeploySuite[] memory suites) internal returns (DerivedDeploy[] memory derived) { + derived = new DerivedDeploy[](suites.length); + for (uint256 i = 0; i < suites.length; i++) { + derived[i] = deriveDeployment(suites[i]); } } } diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol index 45b55f4..e52f7ec 100644 --- a/src/abstract/RainDeployVerifyChain.sol +++ b/src/abstract/RainDeployVerifyChain.sol @@ -8,9 +8,9 @@ import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; /// Thrown when a version's derived address has no code on a network. Either it /// never deployed there, or it is not there any more. /// @param network The network name, as configured in `[rpc_endpoints]`. -/// @param version The version label that is missing. +/// @param suite The suite that is missing. /// @param deployedAddress The address that should hold it. -error NotDeployedOnNetwork(string network, string version, address deployedAddress); +error NotDeployedOnNetwork(string network, string suite, address deployedAddress); /// Thrown when a version's derived address holds code that is not the code its /// creation code produces. @@ -21,29 +21,29 @@ error NotDeployedOnNetwork(string network, string version, address deployedAddre /// contract, not a shortcoming of a single recorded hash — hence a hard failure /// naming the chain and both hashes, rather than a per-chain hash to record. /// @param network The network name, as configured in `[rpc_endpoints]`. -/// @param version The version label that failed. +/// @param suite The suite that failed. /// @param deployedAddress The address checked. /// @param expectedCodeHash The code hash the version's creation code produces. /// @param actualCodeHash The code hash actually found on this network. error CodeHashMismatchOnNetwork( - string network, string version, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash + string network, string suite, address deployedAddress, bytes32 expectedCodeHash, bytes32 actualCodeHash ); /// @title RainDeployVerifyChain /// @notice The only deploy-pin assertions anchored to something outside the /// repo: across every network in `LibRainDeploy.supportedNetworks()`, every -/// recorded version's derived address carries code with its derived code hash. +/// declared suite's derived address carries code with its derived code hash. /// -/// This is the only group that can catch a version that never deployed to a +/// This is the only group that can catch a suite that never deployed to a /// network, or that is not there any more. Neither is a fact the repo can hold: /// both can go false with nobody touching it — a release that reached four /// chains of five, a chain added to `supportedNetworks()` after a release that /// therefore never got it, a deploy that silently failed. /// -/// The matrix is versions by networks and is generated from both, so a new -/// network leaves no version unchecked and a new version is checked on every -/// network from the moment it is recorded. There are deliberately no per-chain -/// or per-version functions to add. +/// The matrix is suites by networks and is generated from both, so a new +/// network leaves no suite unchecked and a new suite is checked on every +/// network from the moment it is declared. There are deliberately no per-chain +/// or per-suite functions to add. /// /// It compares against the DERIVED code hash rather than the recorded one, so /// the creation code stays the only parameter. `RainDeployVerifyOffline` is @@ -58,31 +58,31 @@ error CodeHashMismatchOnNetwork( /// --match-contract` and a CI job select at, and it is structural rather than /// conventional — nothing reachable from the offline contract forks anything. abstract contract RainDeployVerifyChain is RainDeployVerifyBase { - /// Checks one derived version against whichever network is currently + /// Checks one derived suite against whichever network is currently /// selected. /// @param network The network name, for the error only. /// @param derived The derivation to check for. function checkDeployedOnNetwork(string memory network, DerivedDeploy memory derived) internal view { if (derived.deployedAddress.code.length == 0) { - revert NotDeployedOnNetwork(network, derived.version, derived.deployedAddress); + revert NotDeployedOnNetwork(network, derived.suite, derived.deployedAddress); } bytes32 actualCodeHash = derived.deployedAddress.codehash; if (actualCodeHash != derived.bytecodeHash) { revert CodeHashMismatchOnNetwork( - network, derived.version, derived.deployedAddress, derived.bytecodeHash, actualCodeHash + network, derived.suite, derived.deployedAddress, derived.bytecodeHash, actualCodeHash ); } } - /// Checks every derived version against every supported network, forking - /// each network once and checking every version on it. + /// Checks every derived suite against every supported network, forking + /// each network once and checking every suite on it. /// /// The derivations are taken as an argument, already computed, because they /// have to be computed before anything forks: on a fork the derived address /// is the very address the deployment under test occupies, so deriving /// there would either collide with it or read it back as its own /// expectation. - /// @param derived The derivation of every version to check. + /// @param derived The derivation of every suite to check. function checkDeployedOnSupportedNetworks(DerivedDeploy[] memory derived) internal { string[] memory networks = LibRainDeploy.supportedNetworks(); for (uint256 i = 0; i < networks.length; i++) { @@ -96,9 +96,9 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { } } - /// Every recorded version MUST be live, with the code its creation code + /// Every declared suite MUST be live, with the code its creation code /// produces, on every supported network. function testDeployPinsLiveOnEverySupportedNetwork() external { - checkDeployedOnSupportedNetworks(deriveDeployments(allVersions())); + checkDeployedOnSupportedNetworks(deriveDeployments(allSuites())); } } diff --git a/src/abstract/RainDeployVerifyOffline.sol b/src/abstract/RainDeployVerifyOffline.sol index 38acf34..61ba4ae 100644 --- a/src/abstract/RainDeployVerifyOffline.sol +++ b/src/abstract/RainDeployVerifyOffline.sol @@ -2,45 +2,46 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DeployCandidate, DeployVersion, DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; +import {DeployCandidate, DeploySuite} from "./RainDeploySuitesBase.sol"; /// Thrown when the deploy address recorded for a version is not the address its /// own creation code derives. -/// @param version The version label that failed. -/// @param storedAddress The address the version records. +/// @param suite The suite that failed. +/// @param storedAddress The address the suite records. /// @param derivedAddress The address its creation code derives. -error StoredAddressMismatch(string version, address storedAddress, address derivedAddress); +error StoredAddressMismatch(string suite, address storedAddress, address derivedAddress); /// Thrown when the deployed code hash recorded for a version is not the hash /// its own creation code produces. -/// @param version The version label that failed. -/// @param storedCodeHash The code hash the version records. +/// @param suite The suite that failed. +/// @param storedCodeHash The code hash the suite records. /// @param derivedCodeHash The code hash its creation code produces. -error StoredCodeHashMismatch(string version, bytes32 storedCodeHash, bytes32 derivedCodeHash); +error StoredCodeHashMismatch(string suite, bytes32 storedCodeHash, bytes32 derivedCodeHash); /// Thrown when the runtime code recorded for a version does not hash to the /// code hash recorded beside it. -/// @param version The version label that failed. -/// @param storedBytecodeHash The code hash the version records. -/// @param runtimeCodeHash The hash of the runtime code the version records. -error StoredRuntimeCodeHashMismatch(string version, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); +/// @param suite The suite that failed. +/// @param storedBytecodeHash The code hash the suite records. +/// @param runtimeCodeHash The hash of the runtime code the suite records. +error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash); /// Thrown when the candidate's recorded creation code is not the creation code /// this repo currently compiles. Hashes rather than the bytes themselves, which /// run to tens of kilobytes. -/// @param version The candidate's version label. +/// @param suite The candidate's key. /// @param storedCreationCodeHash Hash of the creation code the candidate /// records. /// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the /// contract the candidate claims to be. -error CandidateSourceMismatch(string version, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); +error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); /// @title RainDeployVerifyOffline -/// @notice Every deploy-pin assertion that needs no network, for every version -/// a repo records. Two groups, which catch different things and are documented +/// @notice Every deploy-pin assertion that needs no network, for every suite +/// a repo declares. Two groups, which catch different things and are documented /// as such because it is easy to read the first as covering the second. /// -/// **Internal to the recorded set.** The address a version's creation code +/// **Internal to the recorded set.** The address a suite's creation code /// derives is the address it records, the code hash that creation code produces /// is the code hash it records, and the runtime code it records hashes to that /// same code hash. These are real derivations and they catch a set generated @@ -59,27 +60,27 @@ error CandidateSourceMismatch(string version, bytes32 storedCreationCodeHash, by /// source, so anchoring one to source asserts something that is false by /// design. /// -/// Neither group can catch a version that was never deployed, or that is no +/// Neither group can catch a suite that was never deployed, or that is no /// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a /// substitute for it. abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { - /// Checks one version against itself: derive from its creation code, then + /// Checks one suite against itself: derive from its creation code, then /// require everything it records to agree with the derivation. - /// @param version The version to check. - function checkInternallyConsistent(DeployVersion memory version) internal { - DerivedDeploy memory derived = deriveDeployment(version); + /// @param suite The suite to check. + function checkInternallyConsistent(DeploySuite memory suite) internal { + DerivedDeploy memory derived = deriveDeployment(suite); - if (version.storedDeployedAddress != derived.deployedAddress) { - revert StoredAddressMismatch(version.version, version.storedDeployedAddress, derived.deployedAddress); + if (suite.storedDeployedAddress != derived.deployedAddress) { + revert StoredAddressMismatch(suite.suite, suite.storedDeployedAddress, derived.deployedAddress); } - if (version.storedBytecodeHash != derived.bytecodeHash) { - revert StoredCodeHashMismatch(version.version, version.storedBytecodeHash, derived.bytecodeHash); + if (suite.storedBytecodeHash != derived.bytecodeHash) { + revert StoredCodeHashMismatch(suite.suite, suite.storedBytecodeHash, derived.bytecodeHash); } - bytes32 runtimeCodeHash = keccak256(version.storedRuntimeCode); - if (version.storedBytecodeHash != runtimeCodeHash) { - revert StoredRuntimeCodeHashMismatch(version.version, version.storedBytecodeHash, runtimeCodeHash); + bytes32 runtimeCodeHash = keccak256(suite.storedRuntimeCode); + if (suite.storedBytecodeHash != runtimeCodeHash) { + revert StoredRuntimeCodeHashMismatch(suite.suite, suite.storedBytecodeHash, runtimeCodeHash); } } @@ -88,25 +89,25 @@ abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { function checkAnchoredToSource(DeployCandidate memory candidate) internal pure { if (keccak256(candidate.snapshot.creationCode) != keccak256(candidate.sourceCreationCode)) { revert CandidateSourceMismatch( - candidate.snapshot.version, + candidate.snapshot.suite, keccak256(candidate.snapshot.creationCode), keccak256(candidate.sourceCreationCode) ); } } - /// Every recorded version MUST be internally consistent: what it records is + /// Every declared suite MUST be internally consistent: what it records is /// what its own creation code derives. function testDeployPinsInternallyConsistent() external { - DeployVersion[] memory versions = allVersions(); - for (uint256 i = 0; i < versions.length; i++) { - checkInternallyConsistent(versions[i]); + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + checkInternallyConsistent(suites[i]); } } /// The candidate MUST be a snapshot of the contract this repo compiles, not /// of some other contract that happens to be internally consistent. function testDeployPinsCandidateAnchoredToSource() external pure { - checkAnchoredToSource(candidateVersion()); + checkAnchoredToSource(candidateSuite()); } } diff --git a/test/abstract/AddressRegistryDeployVersions.sol b/test/abstract/AddressRegistryDeployVersions.sol deleted file mode 100644 index 8cc0c36..0000000 --- a/test/abstract/AddressRegistryDeployVersions.sol +++ /dev/null @@ -1,56 +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 {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../src/abstract/RainDeployVerifyBase.sol"; -import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; -import {LibAddressRegistryDeploy} from "../../src/lib/LibAddressRegistryDeploy.sol"; - -/// @title AddressRegistryDeployVersions -/// @notice Every version of `AddressRegistry` this repo records, declared once -/// and inherited by `AddressRegistryDeployPinsOfflineTest` and -/// `AddressRegistryDeployPinsChainTest`. -/// -/// This declaration is the whole of what a deploy repo writes. The verification -/// itself — the derivation from creation code, the comparisons against what is -/// recorded, the source anchor, the networks matrix — is -/// `src/abstract/RainDeployVerify*.sol`'s and is inherited rather than -/// restated. There is deliberately nothing per version and nothing per network -/// here, so neither a new release nor a new supported network adds a test. -/// -/// From the first `sol-v*` release this declaration is what `script/Build.sol` -/// generates, so the frozen snapshot a release cuts is in the enumeration the -/// moment it exists rather than the next time somebody remembers to add a test. -abstract contract AddressRegistryDeployVersions is RainDeployVerifyBase { - /// @inheritdoc RainDeployVerifyBase - /// @dev Empty: no release has been cut, so no snapshot is frozen. - /// `src/generated//` is append-only and `ADDRESS_REGISTRY_ROOT` is - /// still a placeholder, so a snapshot written now could never be corrected. - function releasedVersions() internal pure override returns (DeployVersion[] memory) { - return new DeployVersion[](0); - } - - /// @inheritdoc RainDeployVerifyBase - /// @dev The pins in `LibAddressRegistryDeploy` are hand-written literals, - /// and they are what the internal group checks the derivation against. - /// - /// The creation code and runtime code come from source because nothing - /// records them yet — there is no `src/generated/candidate/` pointers file - /// to read them from. Until there is, the source anchor compares source - /// against itself and can only pass: what it protects against is a RECORDED - /// creation code drifting from the source it claims to be, and there is no - /// recorded creation code here to drift. The address and code hash pins ARE - /// recorded, and those are checked for real. - function candidateVersion() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ - snapshot: DeployVersion({ - version: "candidate", - creationCode: type(AddressRegistry).creationCode, - storedDeployedAddress: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, - storedBytecodeHash: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, - storedRuntimeCode: type(AddressRegistry).runtimeCode - }), - sourceCreationCode: type(AddressRegistry).creationCode - }); - } -} diff --git a/test/abstract/MockDeployVersions.sol b/test/abstract/MockDeploySuites.sol similarity index 65% rename from test/abstract/MockDeployVersions.sol rename to test/abstract/MockDeploySuites.sol index 349e4e3..13fec21 100644 --- a/test/abstract/MockDeployVersions.sol +++ b/test/abstract/MockDeploySuites.sol @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DeployCandidate, DeployVersion, RainDeployVerifyBase} from "../../src/abstract/RainDeployVerifyBase.sol"; +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; import { BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, @@ -17,8 +17,8 @@ import { RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 } from "../fixtures/0_0_2/MockDeployableV2.pointers.sol"; -/// @title MockDeployVersions -/// @notice A deploy repo's version declaration, as a fixture: two frozen +/// @title MockDeploySuites +/// @notice A deploy repo's suite declaration, as a fixture: two frozen /// releases plus a candidate tracking `MockDeployableV2`. /// /// It is declared once, here, and inherited into one `RainDeployVerifyOffline` @@ -32,39 +32,46 @@ import { /// constants, never from `type(X).creationCode`. A release records what was /// deployed; that the contract still exists in this repo is incidental. /// - `0_0_2` and the candidate are the same bytes, which is what a repo looks -/// like between a release and the next source change. Two versions therefore -/// derive one address. +/// like between a release and the next source change. Two suites therefore +/// derive one address, under two distinct keys — each is separately +/// deployable, which is how an old release reaches a chain added after it. /// - The candidate takes its creation code from source, because it has no /// frozen snapshot to take it from — the state `AddressRegistry` is in. -abstract contract MockDeployVersions is RainDeployVerifyBase { - /// @inheritdoc RainDeployVerifyBase - function releasedVersions() internal pure override returns (DeployVersion[] memory versions) { - versions = new DeployVersion[](2); - versions[0] = DeployVersion({ - version: "0_0_1", +abstract contract MockDeploySuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](2); + suites[0] = DeploySuite({ + suite: "mock-deployable-0-0-1", creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + dependencies: new address[](0) }); - versions[1] = DeployVersion({ - version: "0_0_2", + suites[1] = DeploySuite({ + suite: "mock-deployable-v2-0-0-2", creationCode: MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, - storedRuntimeCode: MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 + storedRuntimeCode: MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) }); } - /// @inheritdoc RainDeployVerifyBase - function candidateVersion() internal pure override returns (DeployCandidate memory) { + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { return DeployCandidate({ - snapshot: DeployVersion({ - version: "candidate", + snapshot: DeploySuite({ + suite: "mock-deployable-v2-candidate", creationCode: type(MockDeployableV2).creationCode, storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, - storedRuntimeCode: type(MockDeployableV2).runtimeCode + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) }), sourceCreationCode: type(MockDeployableV2).creationCode }); diff --git a/test/concrete/MockBroadcastDeploy.sol b/test/concrete/MockBroadcastDeploy.sol new file mode 100644 index 0000000..d9b129c --- /dev/null +++ b/test/concrete/MockBroadcastDeploy.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 {DeploySuite} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {RainDeployBroadcast} from "../../src/abstract/RainDeployBroadcast.sol"; +import {MockDeploySuites} from "../abstract/MockDeploySuites.sol"; + +/// @title MockBroadcastDeploy +/// A deploy repo's whole script, as a fixture — the fixture suite declaration +/// plus `RainDeployBroadcast` and nothing else, which is exactly what +/// `script/Deploy.sol` is. The external wrappers exist so a plain `Test` +/// contract can drive the internals without inheriting `Script`. +contract MockBroadcastDeploy is MockDeploySuites, RainDeployBroadcast { + /// @param requested The suite key to select. + /// @return The selected suite. + function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { + return suiteByName(requested); + } + + /// @return Every declared suite. + function externalAllSuites() external pure returns (DeploySuite[] memory) { + return allSuites(); + } + + /// @return The declared keys, comma separated. + function externalSuiteNames() external pure returns (string memory) { + return suiteNames(); + } + + /// @return The networks a broadcast would go to. + function externalDeployNetworks() external view returns (string[] memory) { + return deployNetworks(); + } +} diff --git a/test/concrete/MockDuplicateSuites.sol b/test/concrete/MockDuplicateSuites.sol new file mode 100644 index 0000000..62c88e6 --- /dev/null +++ b/test/concrete/MockDuplicateSuites.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {MockDeployable} from "./MockDeployable.sol"; +import {MockDeployableV2} from "./MockDeployableV2.sol"; + +/// @title MockDuplicateSuites +/// A declaration whose released suite and candidate share a key, which is the +/// one thing a registry must refuse: the key is what selects what gets +/// broadcast, so a duplicate makes the selection ambiguous and leaves one of +/// the two unreachable. Deliberately different CONTRACTS under the one key, so +/// the ambiguity is a real one. +contract MockDuplicateSuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "collides", + creationCode: type(MockDeployable).creationCode, + storedDeployedAddress: address(0), + storedBytecodeHash: bytes32(0), + storedRuntimeCode: hex"", + artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "collides", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: address(0), + storedBytecodeHash: bytes32(0), + storedRuntimeCode: hex"", + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// @return Every declared suite. + function externalAllSuites() external pure returns (DeploySuite[] memory) { + return allSuites(); + } + + /// @param requested The suite key to select. + /// @return The selected suite. + function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { + return suiteByName(requested); + } +} diff --git a/test/src/abstract/RainDeployBroadcast.t.sol b/test/src/abstract/RainDeployBroadcast.t.sol new file mode 100644 index 0000000..d2ceb0c --- /dev/null +++ b/test/src/abstract/RainDeployBroadcast.t.sol @@ -0,0 +1,94 @@ +// 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 {UnknownDeploymentSuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockBroadcastDeploy} from "../../concrete/MockBroadcastDeploy.sol"; + +/// @title RainDeployBroadcastTest +/// @notice The broadcast entry point, driven exactly as the `Manual sol +/// artifacts` workflow drives it — through `DEPLOYMENT_SUITE` and +/// `DEPLOYMENT_KEY` env vars. +/// +/// Nothing here broadcasts. Every case is one that fails before +/// `deployAndBroadcast` is reached, which is the half of `run()` that can be +/// tested without a key, an RPC and real money — and, not coincidentally, the +/// half that decides WHAT would be deployed. +contract RainDeployBroadcastTest is Test { + MockBroadcastDeploy internal sDeploy; + + /// A deploy repo's whole script: the fixture declaration plus + /// `RainDeployBroadcast`. + function setUp() external { + sDeploy = new MockBroadcastDeploy(); + } + + /// A mistyped suite MUST fail naming every valid suite, and MUST do so + /// before `DEPLOYMENT_KEY` is read. `DEPLOYMENT_KEY` is deliberately unset + /// here: if the key were read first, this would fail on the missing key and + /// send the reader after the wrong thing entirely. + function testRunUnknownSuiteRevertsBeforeReadingTheKey() external { + vm.setEnv("DEPLOYMENT_SUITE", "address-registry"); + + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "address-registry", + "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + ) + ); + sDeploy.run(); + } + + /// An unset `DEPLOYMENT_SUITE` MUST be an unknown suite rather than a + /// default. A deploy that picks something when told nothing is how the + /// wrong contract reaches a chain. + function testRunUnsetSuiteReverts() external { + vm.setEnv("DEPLOYMENT_SUITE", ""); + + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "", + "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + ) + ); + sDeploy.run(); + } + + /// The default target set MUST be every supported network, so a + /// deterministic deployment reaches one address on every chain from one + /// dispatch and no repo restates the list. + function testDeployNetworksDefaultsToSupportedNetworks() external view { + string[] memory networks = sDeploy.externalDeployNetworks(); + string[] memory supported = LibRainDeploy.supportedNetworks(); + + assertEq(networks.length, supported.length); + for (uint256 i = 0; i < supported.length; i++) { + assertEq(networks[i], supported[i]); + } + } + + /// The suite a key selects MUST be the one that would be broadcast — the + /// creation code, the artifact path and the recorded pins all come from the + /// same registry entry the verification contracts check. + /// + /// The recorded address and code hash are passed to `deployAndBroadcast` + /// rather than derived at broadcast time on purpose: + /// `LibRainDeploy.deployToNetworks` compares the recorded address against + /// the creation code before it forks anything, and a derived value would + /// make that comparison derived-against-derived. + function testSelectedSuiteCarriesTheRecordedPins() external view { + assertEq( + sDeploy.externalSuiteByName("mock-deployable-0-0-1").storedDeployedAddress, + LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("mock-deployable-0-0-1").creationCode) + ); + assertEq( + sDeploy.externalSuiteByName("mock-deployable-0-0-1").artifactPath, + "test/concrete/MockDeployable.sol:MockDeployable" + ); + } +} diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol new file mode 100644 index 0000000..cdea11b --- /dev/null +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -0,0 +1,121 @@ +// 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 { + DeploySuite, + DuplicateDeploySuite, + UnknownDeploymentSuite +} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {MockBroadcastDeploy} from "../../concrete/MockBroadcastDeploy.sol"; +import {MockDuplicateSuites} from "../../concrete/MockDuplicateSuites.sol"; + +/// @title RainDeploySuitesBaseTest +/// @notice The registry itself: one declaration, keyed lookup, and the two ways +/// a key can be wrong. +/// +/// The registry is what replaces a chain of `else if` arms. `st0x.deploy`'s +/// production script is ten such arms of identical shape, and it restates its +/// valid keys in a revert string that nothing keeps in step with the arms — +/// so the failure message and the actual set of suites are free to drift. Here +/// they are the same array, which is what these tests pin. +contract RainDeploySuitesBaseTest is Test { + MockBroadcastDeploy internal sSuites; + + /// The fixture declaration, as a deploy script would inherit it. + function setUp() external { + sSuites = new MockBroadcastDeploy(); + } + + /// The registry MUST be the released suites followed by the candidate, in + /// declaration order. Both sides of the repo read this one array, which is + /// what makes deploying one thing and verifying another unrepresentable + /// rather than merely unlikely. + function testAllSuitesIsReleasedThenCandidate() external view { + DeploySuite[] memory suites = sSuites.externalAllSuites(); + + assertEq(suites.length, 3); + assertEq(suites[0].suite, "mock-deployable-0-0-1"); + assertEq(suites[1].suite, "mock-deployable-v2-0-0-2"); + assertEq(suites[2].suite, "mock-deployable-v2-candidate"); + } + + /// Every declared key MUST select its own suite. A deploy is dispatched per + /// suite, so each has to be individually selectable — including a frozen + /// release, which is how an old snapshot reaches a chain added after it. + function testEverySuiteIsSelectableByKey() external view { + DeploySuite[] memory suites = sSuites.externalAllSuites(); + + for (uint256 i = 0; i < suites.length; i++) { + DeploySuite memory selected = sSuites.externalSuiteByName(suites[i].suite); + assertEq(selected.suite, suites[i].suite); + assertEq(keccak256(selected.creationCode), keccak256(suites[i].creationCode)); + assertEq(selected.storedDeployedAddress, suites[i].storedDeployedAddress); + assertEq(selected.artifactPath, suites[i].artifactPath); + } + } + + /// Two suites that record the SAME creation code MUST still be selectable + /// apart. `0_0_2` and the candidate are the same bytes at the same address, + /// so the key is the only thing that distinguishes them — and it has to, + /// because they are separately deployable records. + function testSuitesSharingCreationCodeSelectApart() external view { + DeploySuite memory released = sSuites.externalSuiteByName("mock-deployable-v2-0-0-2"); + DeploySuite memory candidate = sSuites.externalSuiteByName("mock-deployable-v2-candidate"); + + assertEq(keccak256(released.creationCode), keccak256(candidate.creationCode)); + assertEq(released.storedDeployedAddress, candidate.storedDeployedAddress); + assertNotEq(keccak256(bytes(released.suite)), keccak256(bytes(candidate.suite))); + } + + /// An unknown key MUST fail naming EVERY valid key. Not one hardcoded + /// string: the list is built from the registry, so it cannot fall behind + /// the suites it describes. + function testUnknownSuiteNamesEveryValidSuite() external { + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "mock-deployable", + "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + ) + ); + sSuites.externalSuiteByName("mock-deployable"); + } + + /// An empty key is just another unknown key, so an unset `DEPLOYMENT_SUITE` + /// reports the valid set rather than silently taking a default. + function testEmptySuiteIsUnknown() external { + vm.expectRevert( + abi.encodeWithSelector( + UnknownDeploymentSuite.selector, + "", + "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + ) + ); + sSuites.externalSuiteByName(""); + } + + /// The reported key list MUST be exactly the registry, in order. + function testSuiteNamesIsTheRegistry() external view { + assertEq( + sSuites.externalSuiteNames(), + "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + ); + } + + /// Two suites under one key MUST fail, on BOTH paths that read the + /// registry. A duplicate makes selection ambiguous and leaves one record + /// unreachable, and it is checked where both the deploy side and the verify + /// side pay for it rather than in either one of them. + function testDuplicateSuiteKeyReverts() external { + MockDuplicateSuites duplicates = new MockDuplicateSuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + duplicates.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + duplicates.externalSuiteByName("collides"); + } +} diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 8e14baa..b4651be 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -9,7 +9,7 @@ import { RainDeployVerifyChain } from "../../../src/abstract/RainDeployVerifyChain.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {MockDeployVersions} from "../../abstract/MockDeployVersions.sol"; +import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; import { BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, @@ -24,7 +24,7 @@ import { /// @notice `RainDeployVerifyChain` inherited by a fixture repo whose versions /// are made live on every network by `setUp`, so the inherited /// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks -/// every network `supportedNetworks()` returns and finds all three versions. +/// every network `supportedNetworks()` returns and finds all three suites. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the /// fixture at some real deployment in another repo. A real one would make this @@ -38,7 +38,7 @@ import { /// the assertion. `testChainCodeHashMismatchReverts` is what proves it: it /// leaves the etch in place and changes only the code, and the check still /// fails, which it could not do if the expectation were read from the etch. -contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain { +contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// Makes every fixture version live on every fork, which is what the /// inherited test then verifies. Persistent so it survives each /// `createSelectFork` inside the loop. @@ -69,24 +69,24 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "0_0_1", + "mock-deployable-0-0-1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 ) ); this.testDeployPinsLiveOnEverySupportedNetwork(); } - /// EVERY version MUST be checked, not just the first one the matrix + /// EVERY suite MUST be checked, not just the first one the matrix /// reaches. The version missing here is the second and third, so a matrix /// that stopped after the first version would pass. - function testChainNotDeployedRevertsForALaterVersion() external { + function testChainNotDeployedRevertsForALaterSuite() external { vm.revokePersistent(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2); vm.expectRevert( abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "0_0_2", + "mock-deployable-v2-0-0-2", MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2 ) ); @@ -133,7 +133,7 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "0_0_1", + "mock-deployable-0-0-1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, keccak256(hex"6001") @@ -150,14 +150,16 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain vm.createSelectFork(LibRainDeploy.BASE); DerivedDeploy memory derived = DerivedDeploy({ - version: "0_0_1", deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, bytecodeHash: bytes32(uint256(1)) + suite: "mock-deployable-0-0-1", + deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + bytecodeHash: bytes32(uint256(1)) }); vm.expectRevert( abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.BASE, - "0_0_1", + "mock-deployable-0-0-1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, bytes32(uint256(1)), MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 @@ -170,7 +172,7 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain /// derivation clears that address to run the creation code there, so if it /// did not put things back, a persistent deployment would be destroyed /// before the networks were ever read — and the matrix would report every - /// version missing everywhere. + /// suite missing everywhere. /// /// The nonce is checked as well as the code, and it is the part that /// discriminates. A local deploy that survived would leave the SAME runtime @@ -183,7 +185,7 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain assertEq(vm.getNonce(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1), 0); assertEq(vm.getNonce(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2), 0); - DerivedDeploy[] memory derived = deriveDeployments(allVersions()); + DerivedDeploy[] memory derived = deriveDeployments(allSuites()); assertEq(derived.length, 3); assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); @@ -194,7 +196,7 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain /// The matrix MUST cover every supported network, not a subset one repo /// happened to list. A network added to `LibRainDeploy.supportedNetworks()` - /// is checked for every recorded version from the moment it is added, which + /// is checked for every declared suite from the moment it is added, which /// is the case no per-chain test function can cover. function testChainMatrixCoversEverySupportedNetwork() external { string[] memory networks = LibRainDeploy.supportedNetworks(); @@ -208,14 +210,17 @@ contract RainDeployVerifyChainTest is MockDeployVersions, RainDeployVerifyChain vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, hex""); DerivedDeploy memory derived = DerivedDeploy({ - version: "0_0_1", + suite: "mock-deployable-0-0-1", deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, bytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 }); vm.expectRevert( abi.encodeWithSelector( - NotDeployedOnNetwork.selector, networks[i], "0_0_1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + NotDeployedOnNetwork.selector, + networks[i], + "mock-deployable-0-0-1", + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 ) ); this.externalCheckDeployedOnNetwork(networks[i], derived); diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifyOffline.t.sol index 8030170..e53af3a 100644 --- a/test/src/abstract/RainDeployVerifyOffline.t.sol +++ b/test/src/abstract/RainDeployVerifyOffline.t.sol @@ -2,7 +2,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DeployCandidate, DeployVersion, ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import {ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBase.sol"; +import {DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import { CandidateSourceMismatch, RainDeployVerifyOffline, @@ -11,7 +12,7 @@ import { StoredRuntimeCodeHashMismatch } from "../../../src/abstract/RainDeployVerifyOffline.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {MockDeployVersions} from "../../abstract/MockDeployVersions.sol"; +import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { @@ -23,7 +24,7 @@ import { /// @title RainDeployVerifyOfflineTest /// @notice `RainDeployVerifyOffline` inherited by a fixture repo, so the -/// inherited tests themselves are the passing case: `MockDeployVersions` +/// inherited tests themselves are the passing case: `MockDeploySuites` /// declares two frozen releases and a candidate, and /// `testDeployPinsInternallyConsistent` / /// `testDeployPinsCandidateAnchoredToSource` run over them here exactly as they @@ -34,12 +35,12 @@ import { /// inherited tests do, through external wrappers so `vm.expectRevert` lands at /// the right call depth, with the fixture data deliberately broken one field at /// a time. -contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffline { +contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOffline { /// External wrapper for `checkInternallyConsistent` so `vm.expectRevert` /// works at the correct call depth. - /// @param version The version to check. - function externalCheckInternallyConsistent(DeployVersion memory version) external { - checkInternallyConsistent(version); + /// @param suite The suite to check. + function externalCheckInternallyConsistent(DeploySuite memory suite) external { + checkInternallyConsistent(suite); } /// External wrapper for `checkAnchoredToSource` so `vm.expectRevert` works @@ -57,12 +58,14 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// @return The wrong-contract candidate. function wrongContractCandidate() internal pure returns (DeployCandidate memory) { return DeployCandidate({ - snapshot: DeployVersion({ - version: "candidate", + snapshot: DeploySuite({ + suite: "mock-deployable-v2-candidate", creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + dependencies: new address[](0) }), sourceCreationCode: type(MockDeployableV2).creationCode }); @@ -70,14 +73,16 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// The frozen `0_0_1` release, which every negative case below breaks one /// field of. - /// @return The consistent `0_0_1` version. - function consistentVersion() internal pure returns (DeployVersion memory) { - return DeployVersion({ - version: "0_0_1", + /// @return The consistent `0_0_1` suite. + function consistentSuite() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "mock-deployable-0-0-1", creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 + storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + dependencies: new address[](0) }); } @@ -85,30 +90,36 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// derives MUST fail, naming the version and both addresses. This is the /// hand-edited constant, and the address copied from the wrong tag. function testStoredAddressMismatchReverts() external { - DeployVersion memory version = consistentVersion(); - version.storedDeployedAddress = address(0xdead); + DeploySuite memory suite = consistentSuite(); + suite.storedDeployedAddress = address(0xdead); vm.expectRevert( abi.encodeWithSelector( - StoredAddressMismatch.selector, "0_0_1", address(0xdead), MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + StoredAddressMismatch.selector, + "mock-deployable-0-0-1", + address(0xdead), + MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 ) ); - this.externalCheckInternallyConsistent(version); + this.externalCheckInternallyConsistent(suite); } /// A recorded code hash that is not the one the recorded creation code /// produces MUST fail, naming the version and both hashes. This is a /// snapshot regenerated for one field and not the others. function testStoredCodeHashMismatchReverts() external { - DeployVersion memory version = consistentVersion(); - version.storedBytecodeHash = bytes32(uint256(1)); + DeploySuite memory suite = consistentSuite(); + suite.storedBytecodeHash = bytes32(uint256(1)); vm.expectRevert( abi.encodeWithSelector( - StoredCodeHashMismatch.selector, "0_0_1", bytes32(uint256(1)), MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + StoredCodeHashMismatch.selector, + "mock-deployable-0-0-1", + bytes32(uint256(1)), + MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 ) ); - this.externalCheckInternallyConsistent(version); + this.externalCheckInternallyConsistent(suite); } /// Recorded runtime code that does not hash to the code hash recorded @@ -117,15 +128,18 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// only check standing between a corrupted `RUNTIME_CODE` and a green /// suite. function testStoredRuntimeCodeHashMismatchReverts() external { - DeployVersion memory version = consistentVersion(); - version.storedRuntimeCode = hex"00"; + DeploySuite memory suite = consistentSuite(); + suite.storedRuntimeCode = hex"00"; vm.expectRevert( abi.encodeWithSelector( - StoredRuntimeCodeHashMismatch.selector, "0_0_1", MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, keccak256(hex"00") + StoredRuntimeCodeHashMismatch.selector, + "mock-deployable-0-0-1", + MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + keccak256(hex"00") ) ); - this.externalCheckInternallyConsistent(version); + this.externalCheckInternallyConsistent(suite); } /// The internal group MUST NOT catch a consistent snapshot of the wrong @@ -157,7 +171,7 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl vm.expectRevert( abi.encodeWithSelector( CandidateSourceMismatch.selector, - "candidate", + "mock-deployable-v2-candidate", keccak256(MOCK_DEPLOYABLE_CREATION_CODE_0_0_1), keccak256(type(MockDeployableV2).creationCode) ) @@ -169,22 +183,22 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// the previous test is discriminating rather than a check that always /// fails. function testCandidateAnchoredToSourcePasses() external view { - this.externalCheckAnchoredToSource(candidateVersion()); + this.externalCheckAnchoredToSource(candidateSuite()); } - /// Two versions that record the SAME creation code MUST both derive, which + /// Two suites that record the SAME creation code MUST both derive, which /// is the ordinary state of a repo between a release and the next source /// change. `0_0_2` and the candidate are the same bytes and therefore the /// same address, and the whole set still passes. function testVersionsSharingCreationCodeAllDerive() external { - DeployVersion[] memory versions = allVersions(); - assertEq(versions.length, 3); - assertEq(versions[1].storedDeployedAddress, versions[2].storedDeployedAddress); - assertEq(keccak256(versions[1].creationCode), keccak256(versions[2].creationCode)); + DeploySuite[] memory suites = allSuites(); + assertEq(suites.length, 3); + assertEq(suites[1].storedDeployedAddress, suites[2].storedDeployedAddress); + assertEq(keccak256(suites[1].creationCode), keccak256(suites[2].creationCode)); // Neither derivation is disturbed by the other. - this.externalCheckInternallyConsistent(versions[1]); - this.externalCheckInternallyConsistent(versions[2]); + this.externalCheckInternallyConsistent(suites[1]); + this.externalCheckInternallyConsistent(suites[2]); } /// The pure address formula and the factory bytecode the derivation etches @@ -195,7 +209,7 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl /// itself, so only a `LibRainDeploy` whose constant and formula had drifted /// apart could produce it. function testZoltuDerivationMismatchReverts() external { - DeployVersion memory version = consistentVersion(); + DeploySuite memory suite = consistentSuite(); // The factory answers with an address that does have code, but is not // the one the creation code derives. @@ -208,27 +222,27 @@ contract RainDeployVerifyOfflineTest is MockDeployVersions, RainDeployVerifyOffl vm.expectRevert( abi.encodeWithSelector( ZoltuDerivationMismatch.selector, - "0_0_1", + "mock-deployable-0-0-1", MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, LibRainDeploy.ZOLTU_FACTORY ) ); - this.externalCheckInternallyConsistent(version); + this.externalCheckInternallyConsistent(suite); } /// The derivation MUST leave nothing behind. A local deploy that survived /// would be compared against itself by the chain-anchored group, and every /// network would pass whether or not anything is deployed there. function testDerivationLeavesNoCodeBehind() external { - DeployVersion[] memory versions = allVersions(); - for (uint256 i = 0; i < versions.length; i++) { - assertEq(versions[i].storedDeployedAddress.code.length, 0); + DeploySuite[] memory suites = allSuites(); + for (uint256 i = 0; i < suites.length; i++) { + assertEq(suites[i].storedDeployedAddress.code.length, 0); } - this.externalCheckInternallyConsistent(versions[0]); + this.externalCheckInternallyConsistent(suites[0]); - for (uint256 i = 0; i < versions.length; i++) { - assertEq(versions[i].storedDeployedAddress.code.length, 0); + for (uint256 i = 0; i < suites.length; i++) { + assertEq(suites[i].storedDeployedAddress.code.length, 0); } } } diff --git a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol index cff2ba3..c13924d 100644 --- a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol +++ b/test/src/concrete/AddressRegistryDeployPinsChain.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; -import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeployVersions.sol"; +import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; /// @title AddressRegistryDeployPinsChainTest /// @notice Whether `AddressRegistry` is actually live, with the code this repo @@ -25,4 +25,4 @@ import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeplo /// --no-match-contract Chain` still verifies everything that holds offline, /// whether the deployment is missing or the RPC endpoints are merely /// unreachable. -contract AddressRegistryDeployPinsChainTest is AddressRegistryDeployVersions, RainDeployVerifyChain {} +contract AddressRegistryDeployPinsChainTest is AddressRegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol b/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol index b20bfe8..502f00d 100644 --- a/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol +++ b/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; import {RainDeployVerifyOffline} from "../../../src/abstract/RainDeployVerifyOffline.sol"; -import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeployVersions.sol"; +import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; /// @title AddressRegistryDeployPinsOfflineTest /// @notice The deploy-pin assertions for `AddressRegistry` that need no @@ -18,6 +18,6 @@ import {AddressRegistryDeployVersions} from "../../abstract/AddressRegistryDeplo /// so changing the root moves both pins and turns this red until they follow. /// /// Both assertions are inherited. There is nothing to write here, which is the -/// point: `AddressRegistryDeployVersions` says which versions exist and +/// point: `AddressRegistryDeploySuites` says which versions exist and /// `RainDeployVerifyOffline` says what is true of them. -contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeployVersions, RainDeployVerifyOffline {} +contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeploySuites, RainDeployVerifyOffline {} diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index f33f5fa..461fd44 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -795,6 +795,13 @@ contract LibRainDeployTest is Test { /// to nothing at all if the length were not checked. function testCheckResolvedAddressesUnreadableTargetReverts(address target, address account) external { vm.assume(target.code.length == 0); + // The low address space is reserved for precompiles, which have no code + // and yet DO answer — the identity precompile echoes its calldata back, + // so a read of one returns data rather than nothing. They are not + // instances of the case under test. Bounded by the reserved range rather + // than by listing today's precompiles, so a chain or fork that adds one + // does not reintroduce this. + vm.assume(uint160(target) > 0xffff); vm.expectRevert( abi.encodeWithSelector( From 7c600805cdc1590af6084270c5c878c8942e9b06 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 17:18:44 +0000 Subject: [PATCH 11/29] refactor(build): use the codegen library for codegen, don't reimplement it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `script/Build.sol` imported `rain-sol-codegen` and then kept private copies of two things that library publishes: `addressConstantString` and `deployTag`. Both are gone. Neither existed in the pinned `0.1.0` — `addressConstantString` first ships in `sol-v0.1.2` and `LibSnapshot` in `sol-v0.1.1` — so this bumps `rain-sol-codegen` 0.1.0 -> 0.1.3, which is purely ADDITIVE over 0.1.0: lines 1-259 of `LibCodeGen` are byte-identical and `LibFs` is unchanged. 0.1.4 is deliberately NOT taken; it renames `LibFs.pathForContract` from `.pointers.sol` to `.sol` and edits `filePrefix()`, either of which would move generated bytes and the generated file's path. The generated output is byte-identical, proven rather than argued: `Build.sol` was run in a throwaway copy of the repo before and after, and both the pointers file and the alias lib match by `diff` and by md5. `LibCodeGen`'s version is strictly more general — parameterized over the comment and the constant name — and its line-length branch does not fire here (88 chars against a 120 limit), so the separator stays a single space exactly as the local copy hardcoded. `LibSnapshot.deployTag` and `dirForTag` replace the local tag derivation and the restated `src/generated/` concatenation. `LibSnapshot`'s own NatSpec calls itself "the single definition of the tag form", and a second definition in this file is exactly the drift that makes a release freeze the wrong directory. `frozenPathForContract` is NOT adopted: `LibFs.buildFileForContract` owns writing a generated file — its header, and the idempotent removal of an existing one — and takes a contract name rather than a path. Folding the tag into that name lands on the identical path, which the comment now records. --- .coderabbitai.yaml | 1 - foundry.toml | 2 +- remappings.txt | 2 +- script/Build.sol | 74 ++++++++++++++++++++-------------------------- soldeer.lock | 7 +++++ 5 files changed, 41 insertions(+), 45 deletions(-) diff --git a/.coderabbitai.yaml b/.coderabbitai.yaml index 63c3ccb..c6e9c57 100644 --- a/.coderabbitai.yaml +++ b/.coderabbitai.yaml @@ -1,6 +1,5 @@ # SPDX-License-Identifier: LicenseRef-DCL-1.0 # SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd - reviews: path_filters: - "!audit/**" diff --git a/foundry.toml b/foundry.toml index 13539eb..71254da 100644 --- a/foundry.toml +++ b/foundry.toml @@ -35,7 +35,7 @@ fs_permissions = [ [dependencies] forge-std = "1.16.1" -rain-sol-codegen = "0.1.0" +rain-sol-codegen = "0.1.3" [soldeer] recursive_deps = false diff --git a/remappings.txt b/remappings.txt index 905398f..44617eb 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,2 @@ forge-std-1.16.1/=dependencies/forge-std-1.16.1/ -rain-sol-codegen-0.1.0/=dependencies/rain-sol-codegen-0.1.0/ +rain-sol-codegen-0.1.3/=dependencies/rain-sol-codegen-0.1.3/ diff --git a/script/Build.sol b/script/Build.sol index 4b22e6c..e4c014f 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,8 +3,9 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; -import {LibCodeGen} from "rain-sol-codegen-0.1.0/src/lib/LibCodeGen.sol"; -import {LibFs} from "rain-sol-codegen-0.1.0/src/lib/LibFs.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.3/src/lib/LibCodeGen.sol"; +import {LibFs} from "rain-sol-codegen-0.1.3/src/lib/LibFs.sol"; +import {LibSnapshot} from "rain-sol-codegen-0.1.3/src/lib/LibSnapshot.sol"; import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; @@ -13,13 +14,18 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// 1. A frozen per-release snapshot /// `src/generated//AddressRegistry.pointers.sol` (`BYTECODE_HASH`, /// `DEPLOYED_ADDRESS`, `CREATION_CODE`, `RUNTIME_CODE`) for the current -/// `deployTag()`. Historical tags are never regenerated; a release bump -/// writes a new `/` snapshot beside them. +/// `LibSnapshot.deployTag`. Historical tags are never regenerated; a +/// release bump writes a new `/` snapshot beside them. /// 2. `src/lib/LibAddressRegistryDeploy.sol` — the current-release address and -/// codehash, aliased from the current `deployTag()` snapshot so that +/// codehash, aliased from the current tag's snapshot so that /// snapshot stays the single source of truth (never a duplicated literal). /// Kept in `src/lib` so consumers' import path is stable across releases. /// +/// The tag, the snapshot directory and the address-constant formatting all come +/// from `rain-sol-codegen` rather than being restated here — `LibSnapshot` calls +/// itself the single definition of the tag form, and a second definition in this +/// file is exactly the drift that would make a release freeze the wrong dir. +/// /// Run as `forge script script/Build.sol`. Wired into /// `rainix-tag-release`'s `snapshot-generate-cmd`, so a release regenerates the /// pins for the version the tag names. @@ -33,52 +39,35 @@ contract Build is Script { // REUSE-IgnoreEnd - /// @notice The canonical release tag. Read from `foundry.toml` - /// `[package].version` — the single source of truth — with dots converted to - /// underscores for the Solidity dir form (`0.1.6` -> `0_1_6`). - /// @return The tag in its Solidity directory form. - function deployTag() internal view returns (string memory) { - string memory version = vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version"); - bytes memory b = bytes(version); - bytes memory out = new bytes(b.length); - for (uint256 i = 0; i < b.length; i++) { - // forge-lint: disable-next-line(unsafe-typecast) - out[i] = b[i] == "." ? bytes1("_") : b[i]; - } - return string(out); - } - - /// @notice The generated `DEPLOYED_ADDRESS` constant declaration. - /// @param addr The deterministic deploy address. - /// @return The Solidity source for the constant. - function addressConstantString(address addr) internal pure returns (string memory) { - return string.concat( - "\n", - "/// @dev The deterministic deploy address of the contract when deployed via\n", - "/// the Zoltu factory.\n", - "address constant DEPLOYED_ADDRESS = address(", - vm.toString(addr), - ");\n" - ); - } + /// @notice The NatSpec emitted above the generated `DEPLOYED_ADDRESS` + /// constant. An argument to `LibCodeGen.addressConstantString` rather than + /// hardcoded into a local copy of it. + string constant DEPLOYED_ADDRESS_COMMENT = + "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory."; function run() external { LibRainDeploy.etchZoltuFactory(vm); + string memory tag = LibSnapshot.deployTag(vm); + // A fresh version slot has no `/` dir yet, and `vm.writeFile` // won't create one. - vm.createDir(string.concat("src/generated/", deployTag()), true); + vm.createDir(LibSnapshot.dirForTag(tag), true); bytes memory creationCode = type(AddressRegistry).creationCode; address deployed = LibRainDeploy.deployZoltu(creationCode); - // Frozen per-tag snapshot. + // Frozen per-tag snapshot. The tag is folded into the contract name so + // `LibFs` places it at `LibSnapshot.frozenPathForContract(tag, + // "AddressRegistry")`, which is the same path — `LibFs` owns writing a + // generated file, including its header and the idempotent removal of an + // existing one, and there is no variant of it that takes a path. LibFs.buildFileForContract( vm, deployed, - string.concat(deployTag(), "/AddressRegistry"), + string.concat(tag, "/AddressRegistry"), string.concat( - addressConstantString(deployed), + LibCodeGen.addressConstantString(vm, DEPLOYED_ADDRESS_COMMENT, "DEPLOYED_ADDRESS", deployed), LibCodeGen.bytesConstantString( vm, "/// @dev The creation bytecode of the contract.", "CREATION_CODE", creationCode ), @@ -89,16 +78,17 @@ contract Build is Script { ); // Current-release pin lib. - genLibAddressRegistryDeploy(); + genLibAddressRegistryDeploy(tag); } /// @notice (Re)generate `src/lib/LibAddressRegistryDeploy.sol`, aliasing the - /// current `deployTag()` snapshot's `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as - /// the current-release constants — the snapshot stays the single source of + /// current tag's snapshot `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as the + /// current-release constants — the snapshot stays the single source of /// truth (never a duplicated literal). Emitted line-by-line to match the /// generated-file convention. - function genLibAddressRegistryDeploy() internal { - string memory importPath = string.concat("../generated/", deployTag(), "/AddressRegistry.pointers.sol"); + /// @param tag The release tag, from `LibSnapshot.deployTag`. + function genLibAddressRegistryDeploy(string memory tag) internal { + string memory importPath = string.concat("../generated/", tag, "/AddressRegistry.pointers.sol"); vm.writeFile(GEN_LIB_PATH, ""); vm.writeLine(GEN_LIB_PATH, GEN_SPDX_LICENSE); vm.writeLine(GEN_LIB_PATH, GEN_SPDX_COPYRIGHT); diff --git a/soldeer.lock b/soldeer.lock index e9184e4..f7c59be 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -4,3 +4,10 @@ version = "1.16.1" url = "https://soldeer-revisions.s3.amazonaws.com/forge-std/1_16_1_08-05-2026_08:51:16_forge-std-1.16.zip" checksum = "839b61832925c7152c7b6dffbfa4998d9e606211179bd8f604733124e8a7cb57" integrity = "60e55d10150354ca4a1e2985c5456c834b92b82ef85ab0e1d92a7786cddbd219" + +[[dependencies]] +name = "rain-sol-codegen" +version = "0.1.3" +url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_3_15-07-2026_08:55:10_rain.sol.zip" +checksum = "e9b8fa2e32ad2c5e2ea6c4f32a909c4d8969b207a486fdbe9d542f14b4125143" +integrity = "f78a6eee689ce93859736cb7d3b149802168e400688512b8865cf625e0bf3534" From 589686cf8e4a0d802987aabf551c989af13515e2 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 17:32:33 +0000 Subject: [PATCH 12/29] feat(snapshot): candidate/frozen split, LibSnapshot moved in, .pointers dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes that only make sense together. **`LibSnapshot` moves wholesale into rain.deploy** as `src/lib/LibRainDeploySnapshot.sol`. "Which release am I building", "where does its record live" and "freeze it immutably" are release machinery, not code generation, and splitting them across two repos was the homing problem. It had zero callers anywhere — every consumer pins `rain-sol-codegen` 0.1.0, which predates it — so the move costs nothing. `LibCodeGen` stays upstream and stays used for every constant emitted. **Two facts, two homes.** `src/generated/candidate/` is the ROLLING snapshot, regenerated every build, always describing HEAD; `src/generated//` is a FROZEN record of what a release deployed. `LibAddressRegistryDeploy` aliases `candidate`, so consumers' import path never moves. This also makes the source anchor real for the first time: the candidate's creation code is now RECORDED, so `RainDeployVerifyOffline` compares it against `type(AddressRegistry).creationCode` and catches a source edit without a regenerate. Previously it compared source against itself and could only pass. **The ordering is structural.** `freeze` takes the regeneration as an argument and runs it first, in one call. `cutRelease()` is the only way to freeze and it cannot freeze anything it did not just generate, so "freeze, then regenerate" has nowhere to be written. Guards are Solidity and run before any write: strict X.Y.Z, this release not already frozen, something to freeze. Byte-identity of the frozen copy is true by construction — it is the bytes just written, read back — so no comparison afterwards is needed. **`.pointers` is gone**, and `rain-sol-codegen` goes to 0.1.4 to get it. Pointers meant function-pointer tables in the interpreter; a file holding a deploy address, codehash and bytecode has none. 0.1.4 also makes the generated header generic (`AUTOGENERATED BY THE BUILD SCRIPT`) instead of baking in a consumer's script filename, which rainix#304 renames anyway. Generated files are now `.sol`. Orphans deleted rather than annotated: the local `deployTag`, the local `addressConstantString`, the restated `src/generated/` concatenation, the single-stage `run()` that both generated and implicitly froze, and the `fsNameForSnapshot` helper whose only purpose was preserving `.pointers`. `pathForSnapshot` now delegates to `LibFs.pathForContract`, so the path this library freezes FROM is the same definition `LibFs` writes TO. --- CLAUDE.md | 2 +- foundry.toml | 2 +- remappings.txt | 2 +- script/Build.sol | 120 +++++++----- soldeer.lock | 8 +- src/abstract/AddressRegistryDeploySuites.sol | 26 +-- src/generated/candidate/AddressRegistry.sol | 25 +++ src/lib/LibAddressRegistryDeploy.sol | 41 ++-- src/lib/LibRainDeploySnapshot.sol | 178 ++++++++++++++++++ test/abstract/MockDeploySuites.sol | 4 +- ...oyable.pointers.sol => MockDeployable.sol} | 2 +- ...leV2.pointers.sol => MockDeployableV2.sol} | 2 +- test/src/abstract/RainDeployVerifyChain.t.sol | 4 +- .../abstract/RainDeployVerifyOffline.t.sol | 2 +- 14 files changed, 314 insertions(+), 104 deletions(-) create mode 100644 src/generated/candidate/AddressRegistry.sol create mode 100644 src/lib/LibRainDeploySnapshot.sol rename test/fixtures/0_0_1/{MockDeployable.pointers.sol => MockDeployable.sol} (98%) rename test/fixtures/0_0_2/{MockDeployableV2.pointers.sol => MockDeployableV2.sol} (95%) diff --git a/CLAUDE.md b/CLAUDE.md index 1978f68..959efc9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -179,7 +179,7 @@ Nothing is per suite beyond an array entry, and nothing anywhere is per network. The creation code is the only parameter. The Zoltu factory is `CREATE2` over its calldata under a zero salt, so the address is a pure function of it, and running it once locally gives the runtime code and its hash. The address, code hash and -runtime code a pointers file records are checked OUTPUTS. +runtime code a generated file records are checked OUTPUTS. Three groups, sorted by what they are anchored to: diff --git a/foundry.toml b/foundry.toml index 71254da..b03dd4f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -35,7 +35,7 @@ fs_permissions = [ [dependencies] forge-std = "1.16.1" -rain-sol-codegen = "0.1.3" +rain-sol-codegen = "0.1.4" [soldeer] recursive_deps = false diff --git a/remappings.txt b/remappings.txt index 44617eb..d276696 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,2 @@ forge-std-1.16.1/=dependencies/forge-std-1.16.1/ -rain-sol-codegen-0.1.3/=dependencies/rain-sol-codegen-0.1.3/ +rain-sol-codegen-0.1.4/=dependencies/rain-sol-codegen-0.1.4/ diff --git a/script/Build.sol b/script/Build.sol index e4c014f..65b7c47 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,35 +3,43 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; -import {LibCodeGen} from "rain-sol-codegen-0.1.3/src/lib/LibCodeGen.sol"; -import {LibFs} from "rain-sol-codegen-0.1.3/src/lib/LibFs.sol"; -import {LibSnapshot} from "rain-sol-codegen-0.1.3/src/lib/LibSnapshot.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.4/src/lib/LibCodeGen.sol"; +import {LibFs} from "rain-sol-codegen-0.1.4/src/lib/LibFs.sol"; import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; +import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// @title Build -/// @notice Generates the deterministic-deploy pins for `AddressRegistry`: -/// 1. A frozen per-release snapshot -/// `src/generated//AddressRegistry.pointers.sol` (`BYTECODE_HASH`, -/// `DEPLOYED_ADDRESS`, `CREATION_CODE`, `RUNTIME_CODE`) for the current -/// `LibSnapshot.deployTag`. Historical tags are never regenerated; a -/// release bump writes a new `/` snapshot beside them. -/// 2. `src/lib/LibAddressRegistryDeploy.sol` — the current-release address and -/// codehash, aliased from the current tag's snapshot so that -/// snapshot stays the single source of truth (never a duplicated literal). -/// Kept in `src/lib` so consumers' import path is stable across releases. +/// @notice Generates the deterministic-deploy pins for `AddressRegistry`. /// -/// The tag, the snapshot directory and the address-constant formatting all come -/// from `rain-sol-codegen` rather than being restated here — `LibSnapshot` calls -/// itself the single definition of the tag form, and a second definition in this -/// file is exactly the drift that would make a release freeze the wrong dir. +/// Two entry points, because there are two different things to do and only one +/// of them happens on an ordinary build: /// -/// Run as `forge script script/Build.sol`. Wired into -/// `rainix-tag-release`'s `snapshot-generate-cmd`, so a release regenerates the -/// pins for the version the tag names. +/// - `run()` — every build. Regenerates the ROLLING snapshot +/// `src/generated/candidate/AddressRegistry.sol` from current source, +/// and the alias lib that points at it. Nothing here is frozen, so a source +/// change simply moves it. +/// - `cutRelease()` — a release. Regenerates the rolling snapshot and freezes +/// it as `src/generated//`, in ONE call, in that order. +/// +/// The alias lib always points at `candidate`, so consumers' import path never +/// moves and `LibAddressRegistry` always resolves against what this repo +/// currently compiles. The frozen `/` directories are the historical +/// record — what each release actually deployed — which is what +/// `AddressRegistryDeploySuites.releasedSuites()` enumerates. +/// +/// The tag, both snapshot paths and the freeze come from +/// `LibRainDeploySnapshot`; every constant is emitted by `LibCodeGen`; the file +/// itself is written by `LibFs`. Nothing here restates any of them. contract Build is Script { string constant GEN_LIB_PATH = "src/lib/LibAddressRegistryDeploy.sol"; + /// @notice The NatSpec emitted above the generated `DEPLOYED_ADDRESS` + /// constant. An argument to `LibCodeGen.addressConstantString` rather than + /// hardcoded into a local copy of it. + string constant DEPLOYED_ADDRESS_COMMENT = + "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory."; + // REUSE-IgnoreStart (the two SPDX lines below are the header EMITTED into the // generated lib, not this script's own license — hide from reuse lint) string constant GEN_SPDX_LICENSE = "// SPDX-License-Identifier: LicenseRef-DCL-1.0"; @@ -39,33 +47,46 @@ contract Build is Script { // REUSE-IgnoreEnd - /// @notice The NatSpec emitted above the generated `DEPLOYED_ADDRESS` - /// constant. An argument to `LibCodeGen.addressConstantString` rather than - /// hardcoded into a local copy of it. - string constant DEPLOYED_ADDRESS_COMMENT = - "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory."; - + /// @notice Every build: regenerate the rolling snapshot and its alias lib. function run() external { - LibRainDeploy.etchZoltuFactory(vm); + regenerateCandidate(); + genLibAddressRegistryDeploy(); + } - string memory tag = LibSnapshot.deployTag(vm); + /// @notice A release: regenerate the rolling snapshot, then freeze it as + /// this release's immutable record. + /// + /// One invocation, so the ordering is a property of the tool rather than of + /// whoever wrote the release command. `LibRainDeploySnapshot.freeze` takes + /// the regeneration and runs it FIRST; there is no entry point that freezes + /// without regenerating, so a stale freeze has nowhere to come from. + /// + /// Not yet wired into `package-release.yaml`, whose `snapshot-generate-cmd` + /// still calls `run()`. Changing that input is out of scope here. + function cutRelease() external { + string[] memory contractNames = new string[](1); + contractNames[0] = "AddressRegistry"; + LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); + genLibAddressRegistryDeploy(); + } - // A fresh version slot has no `/` dir yet, and `vm.writeFile` + /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` + /// from what this repo currently compiles. + function regenerateCandidate() internal { + LibRainDeploy.etchZoltuFactory(vm); + + // A fresh checkout has no `candidate/` dir yet, and `vm.writeFile` // won't create one. - vm.createDir(LibSnapshot.dirForTag(tag), true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(LibRainDeploySnapshot.dirForSnapshot(LibRainDeploySnapshot.CANDIDATE), true); bytes memory creationCode = type(AddressRegistry).creationCode; address deployed = LibRainDeploy.deployZoltu(creationCode); - // Frozen per-tag snapshot. The tag is folded into the contract name so - // `LibFs` places it at `LibSnapshot.frozenPathForContract(tag, - // "AddressRegistry")`, which is the same path — `LibFs` owns writing a - // generated file, including its header and the idempotent removal of an - // existing one, and there is no variant of it that takes a path. LibFs.buildFileForContract( vm, deployed, - string.concat(tag, "/AddressRegistry"), + LibRainDeploySnapshot.snapshotName(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"), string.concat( LibCodeGen.addressConstantString(vm, DEPLOYED_ADDRESS_COMMENT, "DEPLOYED_ADDRESS", deployed), LibCodeGen.bytesConstantString( @@ -76,25 +97,24 @@ contract Build is Script { ) ) ); - - // Current-release pin lib. - genLibAddressRegistryDeploy(tag); } /// @notice (Re)generate `src/lib/LibAddressRegistryDeploy.sol`, aliasing the - /// current tag's snapshot `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as the - /// current-release constants — the snapshot stays the single source of - /// truth (never a duplicated literal). Emitted line-by-line to match the + /// ROLLING candidate snapshot's `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as the + /// current constants — that snapshot stays the single source of truth + /// (never a duplicated literal), and the import path never moves because + /// `candidate` never moves. Emitted line-by-line to match the /// generated-file convention. - /// @param tag The release tag, from `LibSnapshot.deployTag`. - function genLibAddressRegistryDeploy(string memory tag) internal { - string memory importPath = string.concat("../generated/", tag, "/AddressRegistry.pointers.sol"); + function genLibAddressRegistryDeploy() internal { + string memory importPath = + string.concat("../generated/", LibRainDeploySnapshot.CANDIDATE, "/AddressRegistry.sol"); + //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile(GEN_LIB_PATH, ""); vm.writeLine(GEN_LIB_PATH, GEN_SPDX_LICENSE); vm.writeLine(GEN_LIB_PATH, GEN_SPDX_COPYRIGHT); vm.writeLine(GEN_LIB_PATH, "pragma solidity ^0.8.25;"); vm.writeLine(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY ./script/Build.sol"); + vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."); vm.writeLine(GEN_LIB_PATH, ""); vm.writeLine(GEN_LIB_PATH, "import {"); vm.writeLine(GEN_LIB_PATH, " DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR,"); @@ -102,10 +122,10 @@ contract Build is Script { vm.writeLine(GEN_LIB_PATH, string.concat("} from \"", importPath, "\";")); vm.writeLine(GEN_LIB_PATH, ""); vm.writeLine(GEN_LIB_PATH, "/// @title LibAddressRegistryDeploy"); - vm.writeLine(GEN_LIB_PATH, "/// @notice The deterministic Zoltu deploy address and code hash of the current"); - vm.writeLine(GEN_LIB_PATH, "/// `AddressRegistry` release, aliased from the frozen per-release snapshot in"); - vm.writeLine(GEN_LIB_PATH, "/// `src/generated//AddressRegistry.pointers.sol` so that snapshot stays the"); - vm.writeLine(GEN_LIB_PATH, "/// single source of truth."); + vm.writeLine(GEN_LIB_PATH, "/// @notice The deterministic Zoltu deploy address and code hash of"); + vm.writeLine(GEN_LIB_PATH, "/// `AddressRegistry` as this repo currently compiles it, aliased from the"); + vm.writeLine(GEN_LIB_PATH, "/// rolling `src/generated/candidate/AddressRegistry.sol` snapshot so"); + vm.writeLine(GEN_LIB_PATH, "/// that snapshot stays the single source of truth."); vm.writeLine(GEN_LIB_PATH, "library LibAddressRegistryDeploy {"); vm.writeLine(GEN_LIB_PATH, " address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR;"); vm.writeLine(GEN_LIB_PATH, " bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH;"); diff --git a/soldeer.lock b/soldeer.lock index f7c59be..d0ecd02 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -7,7 +7,7 @@ integrity = "60e55d10150354ca4a1e2985c5456c834b92b82ef85ab0e1d92a7786cddbd219" [[dependencies]] name = "rain-sol-codegen" -version = "0.1.3" -url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_3_15-07-2026_08:55:10_rain.sol.zip" -checksum = "e9b8fa2e32ad2c5e2ea6c4f32a909c4d8969b207a486fdbe9d542f14b4125143" -integrity = "f78a6eee689ce93859736cb7d3b149802168e400688512b8865cf625e0bf3534" +version = "0.1.4" +url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_4_15-07-2026_13:56:09_rain.sol.zip" +checksum = "88e1d8df372c86dbfa45266c2bb53e9ceb95284dabf97623fde1281d350151a2" +integrity = "65422e32cf8ab1c75d345bbf768774467cd920cba03234114a412881dc4a35e7" diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol index 74b1ef2..b3cedb0 100644 --- a/src/abstract/AddressRegistryDeploySuites.sol +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -4,6 +4,10 @@ pragma solidity ^0.8.25; import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; import {AddressRegistry} from "../concrete/AddressRegistry.sol"; +import { + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE +} from "../generated/candidate/AddressRegistry.sol"; import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; /// @title AddressRegistryDeploySuites @@ -33,9 +37,10 @@ import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; /// From the first `sol-v*` release this is what `script/Build.sol` generates. abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase - /// @dev Empty: no release has been cut, so no snapshot is frozen. + /// @dev Empty: no release has been cut, so no `/` snapshot is frozen. /// `src/generated//` is append-only and `ADDRESS_REGISTRY_ROOT` is - /// still a placeholder, so a snapshot written now could never be corrected. + /// still a placeholder, so a release cut now could never be corrected. The + /// rolling `candidate/` snapshot is not frozen and does exist. function releasedSuites() internal pure override returns (DeploySuite[] memory) { return new DeploySuite[](0); } @@ -45,13 +50,12 @@ abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { /// and they are what the internal group checks the derivation against and /// what the broadcast asserts against before it forks anything. /// - /// The creation code and runtime code come from source because nothing - /// records them yet — there is no `src/generated/candidate/` pointers file - /// to read them from. Until there is, the source anchor compares source - /// against itself and can only pass: what it protects against is a RECORDED - /// creation code drifting from the source it claims to be, and there is no - /// recorded creation code here to drift. The address and code hash pins ARE - /// recorded, and those are checked for real. + /// The creation code and runtime code are RECORDED, read from the rolling + /// `src/generated/candidate/` snapshot. That is what makes the source + /// anchor mean something: it compares the recorded creation code against + /// `type(AddressRegistry).creationCode`, so editing the contract without + /// re-running `script/Build.sol` fails. While nothing was recorded, that + /// check compared source against itself and could only pass. /// /// `AddressRegistry` reads nothing and calls nothing at construction, so it /// has no dependency that must already be on chain. @@ -59,10 +63,10 @@ abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { return DeployCandidate({ snapshot: DeploySuite({ suite: "address-registry", - creationCode: type(AddressRegistry).creationCode, + creationCode: ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE, storedDeployedAddress: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_ADDRESS, storedBytecodeHash: LibAddressRegistryDeploy.ADDRESS_REGISTRY_DEPLOYED_CODEHASH, - storedRuntimeCode: type(AddressRegistry).runtimeCode, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE, artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", dependencies: new address[](0) }), diff --git a/src/generated/candidate/AddressRegistry.sol b/src/generated/candidate/AddressRegistry.sol new file mode 100644 index 0000000..d87bd99 --- /dev/null +++ b/src/generated/candidate/AddressRegistry.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +// It is committed to the repository because there is a circular dependency +// between the contract and its generated file. The contract needs the +// generated file to exist so that it can compile, and the generated file +// needs the contract to exist so that it can be compiled. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xd9a6a2f03c1e1851becfedba819a436dcccc81c40ac8df02be6815f0b261d042); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x0B8CAaDADF7c53a1b0Af8A7A8E7F3ca90DE517d6); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052348015600e575f80fd5b5061026a8061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b61004636600461020d565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b610087610082366004610224565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f6020828403121561021d575f80fd5b5035919050565b5f8060408385031215610235575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461025f575f80fd5b80915050925092905056"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b61004636600461020d565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b610087610082366004610224565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f6020828403121561021d575f80fd5b5035919050565b5f8060408385031215610235575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461025f575f80fd5b80915050925092905056"; diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol index 32e12ca..233498b 100644 --- a/src/lib/LibAddressRegistryDeploy.sol +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -2,36 +2,19 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import { + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR, + BYTECODE_HASH as ADDRESS_REGISTRY_HASH +} from "../generated/candidate/AddressRegistry.sol"; + /// @title LibAddressRegistryDeploy /// @notice The deterministic Zoltu deploy address and code hash of -/// `AddressRegistry`. The Zoltu factory is `CREATE2` over its calldata with a -/// zero salt, so the address is a pure function of the creation code and is -/// identical on every network. -/// -/// Both values are derived from the creation code this repo compiles, under -/// this repo's own compiler settings, and are checked against it by -/// `AddressRegistryDeployPinsOfflineTest` — the contract, the settings that -/// compile it and the pins that describe it are all here, so there is no -/// boundary across which they can silently diverge. -/// -/// The root authority is a constant in that creation code, so changing the root -/// moves both values. -/// -/// HAND-WRITTEN FOR NOW. From the first `sol-v*` release this file is -/// regenerated by `script/Build.sol`, aliasing the frozen -/// `src/generated//AddressRegistry.pointers.sol` snapshot so that snapshot -/// is the single source of truth. It already lives at the import path the -/// generated version will occupy, so consumers' imports do not move. No -/// snapshot is frozen yet because `ADDRESS_REGISTRY_ROOT` is still a -/// placeholder and `src/generated//` is append-only: a snapshot written -/// now could never be corrected. +/// `AddressRegistry` as this repo currently compiles it, aliased from the +/// rolling `src/generated/candidate/AddressRegistry.sol` snapshot so +/// that snapshot stays the single source of truth. library LibAddressRegistryDeploy { - /// @dev The deterministic deploy address of `AddressRegistry` when deployed - /// via the Zoltu factory. - address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = 0x0B8CAaDADF7c53a1b0Af8A7A8E7F3ca90DE517d6; - - /// @dev The code hash of `AddressRegistry` once deployed, i.e. `keccak256` - /// over the runtime code its creation code leaves behind. - bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = - 0xd9a6a2f03c1e1851becfedba819a436dcccc81c40ac8df02be6815f0b261d042; + address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR; + bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH; } diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol new file mode 100644 index 0000000..86e2c4c --- /dev/null +++ b/src/lib/LibRainDeploySnapshot.sol @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import {LibFs} from "rain-sol-codegen-0.1.4/src/lib/LibFs.sol"; + +/// Thrown when `[package].version` is not strict `X.Y.Z`. A version like +/// `0.1.7-rc1` maps to the directory `0_1_7-rc1`, which the append-only gate's +/// tag predicate ignores forever — an orphan snapshot nothing protects. Refused +/// rather than frozen. +/// @param version The version read from `foundry.toml`. +error UnreleasableVersion(string version); + +/// Thrown when there is no rolling snapshot to freeze. The property is "there +/// is something to freeze", so this fires on a missing directory and on a +/// missing file within it alike. +/// @param path The rolling path that was expected to exist. +error NothingToFreeze(string path); + +/// Thrown when a release tag already has a frozen snapshot. Frozen snapshots +/// are append-only: a release is cut once, and re-cutting one would replace the +/// record consumers of that release pin against. +/// @param tag The release tag. +/// @param dir The frozen directory that already exists. +error SnapshotAlreadyFrozen(string tag, string dir); + +/// @title LibRainDeploySnapshot +/// @notice Which release is being built, where its record lives, and how it is +/// frozen. Release machinery, not code generation. +/// +/// It lives here rather than in `rain-sol-codegen` deliberately. Emitting a +/// Solidity constant is codegen; reading `[package].version`, deciding a +/// release directory and making that directory immutable is the deploy +/// lifecycle, which is this repo's subject. `LibCodeGen` still emits every +/// constant — that split is the point, not an oversight. +/// +/// ## Two facts, two homes +/// +/// - `src/generated/candidate/` is the ROLLING snapshot: what the current +/// source compiles to, regenerated on every build, always describing HEAD. +/// - `src/generated//` is a FROZEN snapshot: what a release deployed, +/// written once and never again. +/// +/// Conflating them is how a repo ends up unable to say what a published version +/// actually deployed. Consumers pin releases; the candidate is what the next +/// release will be. +/// +/// ## The ordering is structural +/// +/// The freeze reads whatever is on disk, so freezing a stale candidate is +/// silent — the immutability check only fires when a tag is re-cut, which is +/// too late and too rare to rely on. `freeze` therefore takes the regeneration +/// as an argument and runs it FIRST, in the same call. There is no entry point +/// that freezes without regenerating, so "freeze, then regenerate" has nowhere +/// to be written. +/// +/// @dev The consuming repo's `foundry.toml` must grant read access to itself so +/// `deployTag` can read the release version from it: +/// `fs_permissions = [{ access = "read", path = "./foundry.toml" }, ...]` +/// alongside read-write access to `./src`. +library LibRainDeploySnapshot { + /// The rolling snapshot's directory name. A sibling of the frozen tag + /// directories rather than a file beside them, so `src/generated/` reads as + /// `candidate/ 0_1_5/ 0_1_6/` and "which one is current" is answered by + /// looking. + string constant CANDIDATE = "candidate"; + + /// The canonical release tag: `foundry.toml` `[package].version` with dots + /// converted to underscores (`0.1.7` -> `0_1_7`) for the Solidity directory + /// form. The single definition of the tag form — the version in + /// `foundry.toml` is the one source of truth for which release is being + /// built, so every path derives from it rather than restating it. + /// + /// Refuses anything that is not strict `X.Y.Z`. + /// @param vm The Vm instance for file operations. + /// @return The tag. + function deployTag(Vm vm) internal view returns (string memory) { + string memory version = vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version"); + bytes memory versionBytes = bytes(version); + + // Strict X.Y.Z: digits and exactly two dots, no leading or trailing + // dot, no empty component. Checked here rather than by the caller + // because every path below derives from the result. + uint256 dots = 0; + uint256 digitsInComponent = 0; + for (uint256 i = 0; i < versionBytes.length; i++) { + bytes1 char = versionBytes[i]; + if (char == ".") { + if (digitsInComponent == 0) { + revert UnreleasableVersion(version); + } + dots++; + digitsInComponent = 0; + } else if (char >= "0" && char <= "9") { + digitsInComponent++; + } else { + revert UnreleasableVersion(version); + } + } + if (dots != 2 || digitsInComponent == 0) { + revert UnreleasableVersion(version); + } + + bytes memory tagBytes = new bytes(versionBytes.length); + for (uint256 i = 0; i < versionBytes.length; i++) { + // forge-lint: disable-next-line(unsafe-typecast) + tagBytes[i] = versionBytes[i] == "." ? bytes1("_") : versionBytes[i]; + } + return string(tagBytes); + } + + /// The directory holding a snapshot, rolling or frozen. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @return The directory path. + function dirForSnapshot(string memory dir) internal pure returns (string memory) { + return string.concat("src/generated/", dir); + } + + /// The contract name that places a generated file inside a snapshot + /// directory. `LibFs` derives its own path from a contract name and takes + /// no path, so the snapshot directory is folded into the name it is given. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The name of the contract. + /// @return The name to pass to `LibFs.buildFileForContract`. + function snapshotName(string memory dir, string memory contractName) internal pure returns (string memory) { + return string.concat(dir, "/", contractName); + } + + /// The path of a contract's generated file within a snapshot. + /// + /// Delegated to `LibFs` rather than concatenated here, so the path this + /// library freezes FROM is the same definition `LibFs` writes TO. Two + /// spellings of one path is how a freeze silently reads nothing. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The name of the contract. + /// @return The file path. + function pathForSnapshot(string memory dir, string memory contractName) internal pure returns (string memory) { + return LibFs.pathForContract(snapshotName(dir, contractName)); + } + + /// Regenerate the rolling snapshot and freeze it as this release's record, + /// in that order, in one call. + /// + /// Every guard runs before anything is written: + /// + /// - the version must be strict `X.Y.Z` (`deployTag`) + /// - this release must not already be frozen — a release is cut once + /// - there must be something to freeze after regenerating + /// + /// The frozen copy is the bytes just regenerated, read back from disk, so + /// "the record matches the candidate" is true by construction rather than + /// by a comparison afterwards. + /// @param vm The Vm instance for file operations. + /// @param regenerate Rewrites the rolling snapshot. Run first, always. + /// @param contractNames The contracts whose generated files form this + /// release's record. + function freeze(Vm vm, function() internal regenerate, string[] memory contractNames) internal { + string memory tag = deployTag(vm); + string memory frozenDir = dirForSnapshot(tag); + if (vm.exists(frozenDir)) { + revert SnapshotAlreadyFrozen(tag, frozenDir); + } + + regenerate(); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(frozenDir, true); + for (uint256 i = 0; i < contractNames.length; i++) { + string memory rollingPath = pathForSnapshot(CANDIDATE, contractNames[i]); + if (!vm.exists(rollingPath)) { + revert NothingToFreeze(rollingPath); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(pathForSnapshot(tag, contractNames[i]), vm.readFile(rollingPath)); + } + } +} diff --git a/test/abstract/MockDeploySuites.sol b/test/abstract/MockDeploySuites.sol index 13fec21..069b7d8 100644 --- a/test/abstract/MockDeploySuites.sol +++ b/test/abstract/MockDeploySuites.sol @@ -9,13 +9,13 @@ import { CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../fixtures/0_0_1/MockDeployable.pointers.sol"; +} from "../fixtures/0_0_1/MockDeployable.sol"; import { BYTECODE_HASH as MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, CREATION_CODE as MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../fixtures/0_0_2/MockDeployableV2.pointers.sol"; +} from "../fixtures/0_0_2/MockDeployableV2.sol"; /// @title MockDeploySuites /// @notice A deploy repo's suite declaration, as a fixture: two frozen diff --git a/test/fixtures/0_0_1/MockDeployable.pointers.sol b/test/fixtures/0_0_1/MockDeployable.sol similarity index 98% rename from test/fixtures/0_0_1/MockDeployable.pointers.sol rename to test/fixtures/0_0_1/MockDeployable.sol index 9e07d23..bbaa085 100644 --- a/test/fixtures/0_0_1/MockDeployable.pointers.sol +++ b/test/fixtures/0_0_1/MockDeployable.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; // Hand-written fixture, deliberately shaped exactly like the frozen per-release -// snapshot a deploy repo generates at `src/generated//.pointers.sol`. +// snapshot a deploy repo generates at `src/generated//.sol`. // It exists so the verification abstracts are exercised against the real shape // consumers have — four literal constants and no reference to any source // contract — rather than only against values re-derived at test time, which diff --git a/test/fixtures/0_0_2/MockDeployableV2.pointers.sol b/test/fixtures/0_0_2/MockDeployableV2.sol similarity index 95% rename from test/fixtures/0_0_2/MockDeployableV2.pointers.sol rename to test/fixtures/0_0_2/MockDeployableV2.sol index fa7f83e..80d8264 100644 --- a/test/fixtures/0_0_2/MockDeployableV2.pointers.sol +++ b/test/fixtures/0_0_2/MockDeployableV2.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.25; // Hand-written fixture in the frozen per-release snapshot shape, as -// `0_0_1/MockDeployable.pointers.sol` explains. A SECOND release, for two +// `0_0_1/MockDeployable.sol` explains. A SECOND release, for two // reasons neither of which one release covers. // // It is a different contract from `0_0_1`, so the two releases derive different diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index b4651be..ec69e7d 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -14,11 +14,11 @@ import { BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../fixtures/0_0_1/MockDeployable.pointers.sol"; +} from "../../fixtures/0_0_1/MockDeployable.sol"; import { DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../../fixtures/0_0_2/MockDeployableV2.pointers.sol"; +} from "../../fixtures/0_0_2/MockDeployableV2.sol"; /// @title RainDeployVerifyChainTest /// @notice `RainDeployVerifyChain` inherited by a fixture repo whose versions diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifyOffline.t.sol index e53af3a..aabb0f5 100644 --- a/test/src/abstract/RainDeployVerifyOffline.t.sol +++ b/test/src/abstract/RainDeployVerifyOffline.t.sol @@ -20,7 +20,7 @@ import { CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../fixtures/0_0_1/MockDeployable.pointers.sol"; +} from "../../fixtures/0_0_1/MockDeployable.sol"; /// @title RainDeployVerifyOfflineTest /// @notice `RainDeployVerifyOffline` inherited by a fixture repo, so the From a4e5a7ba57e4d853836e01264090cd42a495cb16 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 17:55:47 +0000 Subject: [PATCH 13/29] test(exemplars): the exemplar owns the shape, the compiler owns the values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test/fixtures/` said one thing and meant another: files shaped like frozen release snapshots, claiming to be exemplars, at a path that said fixture, with nothing binding the claim. If `Build.sol` had emitted a fifth constant or renamed one, nothing would have noticed the exemplar had stopped describing reality. Renamed to `test/exemplars/` so the path states which way the arrow points, and `GeneratedSnapshotShapeTest` now checks the real generator's committed output — `src/generated/candidate/AddressRegistry.sol`, written by `script/Build.sol` — against the hand-written exemplar. **No exemplar generator, deliberately.** I built one and deleted it. It emitted through `LibCodeGen` and `LibFs` — the same emitters `Build.sol` uses — which would have reduced every conformance assertion to "the generator is deterministic", which nobody doubted. An exemplar is evidence about a generator only when the generator did not produce it, exactly as `LibParseSlow` is evidence about `LibParse` only because it was derived independently. Values stay hand-updated, and the procedure is written into the files: a wrong paste is caught immediately by the group 1 derivation check, so machine-producing them would buy nothing and cost the test. Five named structural properties, not a whole-file diff — a diff fails for reasons nobody can read: the four snapshot constants exist in order with their types; the exemplar declares what the generator emits; both carry the generated header; neither references a source contract (an import or the contract's name would make a frozen snapshot unusable to the repos that read it without that source); the exemplar carries the operating rule. It caught real drift on its first run — my own edit had dropped the generated header from the exemplar. The rule is written into the exemplar files and CLAUDE.md, replacing the "turns the suite red until they follow" wording that made this ambiguous: values moved means paste the derived value; shape moved by accident means fix the generator; shape moved deliberately means change both in one commit. `foundry.toml` grants read — not write — on `./test`: nothing generates there. --- CLAUDE.md | 41 +++++- foundry.toml | 3 + test/abstract/MockDeploySuites.sol | 6 +- test/exemplars/0_0_1/MockDeployable.sol | 60 +++++++++ test/exemplars/0_0_2/MockDeployableV2.sol | 60 +++++++++ test/fixtures/0_0_1/MockDeployable.sol | 34 ----- test/fixtures/0_0_2/MockDeployableV2.sol | 31 ----- test/src/abstract/RainDeployVerifyChain.t.sol | 10 +- .../abstract/RainDeployVerifyOffline.t.sol | 6 +- test/src/lib/GeneratedSnapshotShape.t.sol | 123 ++++++++++++++++++ 10 files changed, 295 insertions(+), 79 deletions(-) create mode 100644 test/exemplars/0_0_1/MockDeployable.sol create mode 100644 test/exemplars/0_0_2/MockDeployableV2.sol delete mode 100644 test/fixtures/0_0_1/MockDeployable.sol delete mode 100644 test/fixtures/0_0_2/MockDeployableV2.sol create mode 100644 test/src/lib/GeneratedSnapshotShape.t.sol diff --git a/CLAUDE.md b/CLAUDE.md index 959efc9..3566db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,9 +107,44 @@ code hash. **`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the creation code this repo compiles under this repo's own settings and checked -against it by `AddressRegistryDeployPinsOfflineTest`. Hand-written until the -first `sol-v*` release generates it from `src/generated//`; no snapshot is -frozen while the root is a placeholder, because that directory is append-only. +against it by `AddressRegistryDeployPinsOfflineTest`. GENERATED by +`script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. No +`/` snapshot is frozen while the root is a placeholder, because that +directory is append-only. + +### `test/exemplars/`: the exemplar owns the SHAPE, the compiler owns the VALUES + +`test/exemplars/0_0_1/` and `0_0_2/` look like frozen release snapshots and are +consumed as the recorded triples the verification abstracts check against. They +are **hand-written**, and `GeneratedSnapshotShapeTest` checks the real +generator's committed output — `src/generated/candidate/AddressRegistry.sol` — +against them. + +That independence is the instrument. An exemplar is evidence about the generator +ONLY because the generator did not emit it, exactly as `LibParseSlow` is +evidence about `LibParse` only because it was derived separately. A helper that +regenerated exemplars through `LibCodeGen` and `LibFs` — the emitters +`script/Build.sol` itself uses — would reduce the whole conformance suite to +"the generator is deterministic", which nobody doubted. So there is deliberately +no exemplar generator, and adding one would void the tests. + +The rule resolves every case, and is written into the exemplar files themselves +so it is in front of whoever hits the red: + +- **compiler or optimiser settings moved the VALUES** — update the literals by + hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` fails + with both the stored and the derived value in its message; paste the derived + one. Four literals, and a wrong paste is caught immediately by that same + derivation check — which is why machine-producing them would buy nothing and + cost the conformance test. +- **the generator emitted a different SHAPE by accident** — fix + `script/Build.sol`. Never edit the exemplar to match; it is the thing being + conformed to. +- **you want a different shape deliberately** — change the exemplar and the + generator in one commit. The conformance test proves they agree. + +Shape disputes are settled by the exemplar. Value disputes are settled by the +compiler. They never contest the same thing. **`src/lib/LibAddressRegistry.sol`** — reads that registry at its deterministic address, verifying its code hash first, exactly as `LibRainDeploy` verifies diff --git a/foundry.toml b/foundry.toml index b03dd4f..82bef7c 100644 --- a/foundry.toml +++ b/foundry.toml @@ -31,6 +31,9 @@ bytecode_hash = "none" fs_permissions = [ { access = "read", path = "./foundry.toml" }, { access = "read-write", path = "./src" }, + # GeneratedSnapshotShapeTest reads test/exemplars/ as text to check the + # generator's committed output against a hand-written shape. + { access = "read", path = "./test" }, ] [dependencies] diff --git a/test/abstract/MockDeploySuites.sol b/test/abstract/MockDeploySuites.sol index 069b7d8..8c5bd25 100644 --- a/test/abstract/MockDeploySuites.sol +++ b/test/abstract/MockDeploySuites.sol @@ -9,16 +9,16 @@ import { CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../fixtures/0_0_1/MockDeployable.sol"; +} from "../exemplars/0_0_1/MockDeployable.sol"; import { BYTECODE_HASH as MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, CREATION_CODE as MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../fixtures/0_0_2/MockDeployableV2.sol"; +} from "../exemplars/0_0_2/MockDeployableV2.sol"; /// @title MockDeploySuites -/// @notice A deploy repo's suite declaration, as a fixture: two frozen +/// @notice A deploy repo's suite declaration, as a exemplar: two frozen /// releases plus a candidate tracking `MockDeployableV2`. /// /// It is declared once, here, and inherited into one `RainDeployVerifyOffline` diff --git a/test/exemplars/0_0_1/MockDeployable.sol b/test/exemplars/0_0_1/MockDeployable.sol new file mode 100644 index 0000000..d0bdee6 --- /dev/null +++ b/test/exemplars/0_0_1/MockDeployable.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. +// +// It is committed to the repository because there is a circular dependency +// between the contract and its generated file. The contract needs the +// generated file to exist so that it can compile, and the generated file +// needs the contract to exist so that it can be compiled. +// +// ...except this particular file is not autogenerated. It is a hand-written +// EXEMPLAR of a file that is, carrying that header because reproducing the +// header is part of reproducing the shape. See below. + +// THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. +// +// This file is HAND-WRITTEN, and that is the point. It is the authority on what +// a generated deploy snapshot LOOKS like — which constants exist, of what type, +// in what order, under what header, with no reference to the contract they came +// from — and `GeneratedSnapshotShapeTest` checks the real generator's committed +// output against it. +// +// It is evidence about the generator ONLY because the generator did not produce +// it, exactly as a slow reference implementation is evidence about a fast one +// only when it was derived independently. Regenerate this file from the +// generator and the conformance test collapses into a determinism check that +// passes however far both have drifted. +// +// What to do when something here goes red: +// +// - a compiler or optimiser change moved the VALUES: update the literals below +// by hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` +// fails with both the stored and the derived value in its message — paste the +// derived one. Four literals, and a wrong paste is caught immediately by that +// same derivation check, which is why machine-producing them would buy +// nothing and cost the test. +// - the generator started emitting a DIFFERENT SHAPE by accident: fix +// `script/Build.sol`. Do not touch this file; it is the thing being conformed +// to. +// - you want a different shape deliberately: change this file and the generator +// in one commit. The conformance test proves they agree. +// +// Shape disputes are settled here. Value disputes are settled by the compiler. +// They never contest the same thing. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/exemplars/0_0_2/MockDeployableV2.sol b/test/exemplars/0_0_2/MockDeployableV2.sol new file mode 100644 index 0000000..b690147 --- /dev/null +++ b/test/exemplars/0_0_2/MockDeployableV2.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. +// +// It is committed to the repository because there is a circular dependency +// between the contract and its generated file. The contract needs the +// generated file to exist so that it can compile, and the generated file +// needs the contract to exist so that it can be compiled. +// +// ...except this particular file is not autogenerated. It is a hand-written +// EXEMPLAR of a file that is, carrying that header because reproducing the +// header is part of reproducing the shape. See below. + +// THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. +// +// This file is HAND-WRITTEN, and that is the point. It is the authority on what +// a generated deploy snapshot LOOKS like — which constants exist, of what type, +// in what order, under what header, with no reference to the contract they came +// from — and `GeneratedSnapshotShapeTest` checks the real generator's committed +// output against it. +// +// It is evidence about the generator ONLY because the generator did not produce +// it, exactly as a slow reference implementation is evidence about a fast one +// only when it was derived independently. Regenerate this file from the +// generator and the conformance test collapses into a determinism check that +// passes however far both have drifted. +// +// What to do when something here goes red: +// +// - a compiler or optimiser change moved the VALUES: update the literals below +// by hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` +// fails with both the stored and the derived value in its message — paste the +// derived one. Four literals, and a wrong paste is caught immediately by that +// same derivation check, which is why machine-producing them would buy +// nothing and cost the test. +// - the generator started emitting a DIFFERENT SHAPE by accident: fix +// `script/Build.sol`. Do not touch this file; it is the thing being conformed +// to. +// - you want a different shape deliberately: change this file and the generator +// in one commit. The conformance test proves they agree. +// +// Shape disputes are settled here. Value disputes are settled by the compiler. +// They never contest the same thing. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/fixtures/0_0_1/MockDeployable.sol b/test/fixtures/0_0_1/MockDeployable.sol deleted file mode 100644 index bbaa085..0000000 --- a/test/fixtures/0_0_1/MockDeployable.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// Hand-written fixture, deliberately shaped exactly like the frozen per-release -// snapshot a deploy repo generates at `src/generated//.sol`. -// It exists so the verification abstracts are exercised against the real shape -// consumers have — four literal constants and no reference to any source -// contract — rather than only against values re-derived at test time, which -// would check the derivation against itself. -// -// A released snapshot is FROZEN. `CREATION_CODE` is a literal here rather than -// `type(MockDeployable).creationCode` for exactly the reason a released tag is -// never anchored to current source: the snapshot records what was deployed, and -// nothing requires the contract that produced it to still exist. -// -// The values are `MockDeployable` under this repo's pinned compiler settings. -// They are pins, so a settings change moves them and turns the suite red until -// they follow, which is the point. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/fixtures/0_0_2/MockDeployableV2.sol b/test/fixtures/0_0_2/MockDeployableV2.sol deleted file mode 100644 index 80d8264..0000000 --- a/test/fixtures/0_0_2/MockDeployableV2.sol +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// Hand-written fixture in the frozen per-release snapshot shape, as -// `0_0_1/MockDeployable.sol` explains. A SECOND release, for two -// reasons neither of which one release covers. -// -// It is a different contract from `0_0_1`, so the two releases derive different -// addresses, which is what a repo with a version history actually looks like. -// -// It records the same creation code the candidate compiles, which is the -// ordinary state of a deploy repo between a release and the next source change: -// the newest release and the candidate ARE the same bytes, so they derive the -// same address, and a derivation that could not run twice for one address would -// break on the common case rather than an exotic one. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index ec69e7d..9ed715d 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -14,20 +14,20 @@ import { BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../fixtures/0_0_1/MockDeployable.sol"; +} from "../../exemplars/0_0_1/MockDeployable.sol"; import { DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../../fixtures/0_0_2/MockDeployableV2.sol"; +} from "../../exemplars/0_0_2/MockDeployableV2.sol"; /// @title RainDeployVerifyChainTest -/// @notice `RainDeployVerifyChain` inherited by a fixture repo whose versions +/// @notice `RainDeployVerifyChain` inherited by a exemplar repo whose versions /// are made live on every network by `setUp`, so the inherited /// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks /// every network `supportedNetworks()` returns and finds all three suites. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the -/// fixture at some real deployment in another repo. A real one would make this +/// exemplar at some real deployment in another repo. A real one would make this /// suite fail whenever that unrelated deployment moved — which is precisely the /// signal this group exists to raise for its own repo, and precisely the wrong /// thing to import into this one. @@ -39,7 +39,7 @@ import { /// leaves the etch in place and changes only the code, and the check still /// fails, which it could not do if the expectation were read from the etch. contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { - /// Makes every fixture version live on every fork, which is what the + /// Makes every exemplar version live on every fork, which is what the /// inherited test then verifies. Persistent so it survives each /// `createSelectFork` inside the loop. function setUp() external { diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifyOffline.t.sol index aabb0f5..128bc01 100644 --- a/test/src/abstract/RainDeployVerifyOffline.t.sol +++ b/test/src/abstract/RainDeployVerifyOffline.t.sol @@ -20,10 +20,10 @@ import { CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../fixtures/0_0_1/MockDeployable.sol"; +} from "../../exemplars/0_0_1/MockDeployable.sol"; /// @title RainDeployVerifyOfflineTest -/// @notice `RainDeployVerifyOffline` inherited by a fixture repo, so the +/// @notice `RainDeployVerifyOffline` inherited by a exemplar repo, so the /// inherited tests themselves are the passing case: `MockDeploySuites` /// declares two frozen releases and a candidate, and /// `testDeployPinsInternallyConsistent` / @@ -33,7 +33,7 @@ import { /// The rest is what each group CATCHES, and — for the internal group — what it /// provably does not. Every case drives the same internal functions the /// inherited tests do, through external wrappers so `vm.expectRevert` lands at -/// the right call depth, with the fixture data deliberately broken one field at +/// the right call depth, with the exemplar data deliberately broken one field at /// a time. contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOffline { /// External wrapper for `checkInternallyConsistent` so `vm.expectRevert` diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol new file mode 100644 index 0000000..68b60f3 --- /dev/null +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -0,0 +1,123 @@ +// 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"; + +/// @title GeneratedSnapshotShapeTest +/// @notice THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. This is +/// what makes the first half true rather than aspirational. +/// +/// It checks the REAL generator's committed output — +/// `src/generated/candidate/AddressRegistry.sol`, written by +/// `script/Build.sol` — against `test/exemplars/`, which is hand-written. +/// +/// The independence is the whole instrument. The exemplar is evidence about the +/// generator precisely because the generator did not emit it: a helper that +/// regenerated exemplars through `LibCodeGen` and `LibFs` — the emitters +/// `Build.sol` itself uses — would reduce every assertion below to "the +/// generator is deterministic", which nobody doubted. So the exemplars are +/// maintained by hand and this reads both files as text. +/// +/// It asserts NAMED STRUCTURAL PROPERTIES rather than diffing whole files. A +/// diff fails for reasons nobody can read, and one exemplar compared once is +/// already a weak instrument; naming each property means a failure says which +/// one broke. Values are deliberately not compared — the two files describe +/// different contracts, and a solc bump moves every literal in both without +/// changing anything this test is about. +contract GeneratedSnapshotShapeTest is Test { + /// The real generator's output, as committed. + /// @return The file contents. + function generated() internal view returns (string memory) { + return vm.readFile("src/generated/candidate/AddressRegistry.sol"); + } + + /// The hand-written exemplar. + /// @return The file contents. + function exemplar() internal view returns (string memory) { + return vm.readFile("test/exemplars/0_0_1/MockDeployable.sol"); + } + + /// The ordered constant DECLARATIONS in a file, values stripped: + /// `bytes32 constant BYTECODE_HASH` and so on. This is the shape. + /// @param content The file to read. + /// @return declarations One entry per constant, in file order. + function constantDeclarations(string memory content) internal pure returns (string[] memory declarations) { + string[] memory lines = vm.split(content, "\n"); + string[] memory found = new string[](lines.length); + uint256 count = 0; + for (uint256 i = 0; i < lines.length; i++) { + // A declaration line, not a comment describing one. + if (!vm.contains(lines[i], " constant ") || vm.contains(lines[i], "//")) { + continue; + } + // Everything before the assignment is the declaration; everything + // after it is a value, which this test has no opinion about. + found[count] = vm.split(lines[i], " =")[0]; + count++; + } + declarations = new string[](count); + for (uint256 i = 0; i < count; i++) { + declarations[i] = found[i]; + } + } + + /// PROPERTY: the generator emits exactly the four constants a deploy + /// snapshot is for, in this order. Named literally, because the exemplar's + /// authority comes from a human having written down what a snapshot SHOULD + /// be — not from whatever the generator currently does. + function testGeneratorEmitsTheDeploySnapshotConstantsInOrder() external view { + string[] memory declarations = constantDeclarations(generated()); + + assertEq(declarations.length, 4, "generator emitted an unexpected number of constants"); + assertEq(declarations[0], "bytes32 constant BYTECODE_HASH"); + assertEq(declarations[1], "address constant DEPLOYED_ADDRESS"); + assertEq(declarations[2], "bytes constant CREATION_CODE"); + assertEq(declarations[3], "bytes constant RUNTIME_CODE"); + } + + /// PROPERTY: the exemplar declares the same constants, of the same types, + /// in the same order, as the generator's real output. This is the + /// conformance itself — a fifth constant, a rename, a reorder or a changed + /// type breaks it, and none of those is something a compiler can cause. + function testExemplarDeclaresWhatTheGeneratorEmits() external view { + string[] memory fromGenerator = constantDeclarations(generated()); + string[] memory fromExemplar = constantDeclarations(exemplar()); + + assertEq(fromExemplar.length, fromGenerator.length, "exemplar and generator disagree on constant count"); + for (uint256 i = 0; i < fromGenerator.length; i++) { + assertEq(fromExemplar[i], fromGenerator[i]); + } + } + + /// PROPERTY: both carry the generated-file header. It is what tells a + /// reader the file is not to be hand-edited, and an exemplar without it + /// would be describing something the generator does not produce. + function testBothCarryTheGeneratedHeader() external view { + string memory header = "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."; + assertTrue(vm.contains(generated(), header), "generator stopped emitting the header"); + assertTrue(vm.contains(exemplar(), header), "exemplar is missing the generated header"); + } + + /// PROPERTY: a snapshot references no source contract. It is read by repos + /// that do not have that source — which is the whole reason a frozen + /// release stays verifiable after its contract has changed or gone — so an + /// import, or the contract's own name, would make it unusable. + function testNeitherReferencesASourceContract() external view { + assertFalse(vm.contains(generated(), "import "), "generator emitted an import"); + assertFalse(vm.contains(generated(), "AddressRegistry"), "generator referenced its source contract"); + + assertFalse(vm.contains(exemplar(), "import "), "exemplar carries an import"); + assertFalse(vm.contains(exemplar(), "MockDeployable"), "exemplar references a source contract"); + } + + /// PROPERTY: the exemplar carries the operating rule, because the rule is + /// what someone reads when this suite goes red. + function testExemplarCarriesTheOperatingRule() external view { + assertTrue( + vm.contains(exemplar(), "THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES."), + "exemplar is missing the operating rule" + ); + assertTrue(vm.contains(exemplar(), "This file is HAND-WRITTEN"), "exemplar no longer states it is hand-written"); + } +} From 721186fe0e69b8137c66410116f8a7324cc831fb Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 18:07:18 +0000 Subject: [PATCH 14/29] test(snapshots): generate the test records, specify their shape from the AST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator is now parameterised rather than special-cased: `LibRainDeploySnapshot.writeSnapshot(vm, outputRoot, dir, contractName, creationCode)`. `script/Build.sol` calls it with `src/generated` for the real deploy record and `script/BuildTestSnapshots.sol` calls it with `test/generated` for the mocks — same code path, different declaration and root. `test/` is excluded by `.soldeerignore`, so mock records never ship in the package, which is why they are not under `src/generated/`. That restores two contracts at two addresses, which is what a repo with a version history actually looks like, and it means there is no hand-maintained hex left anywhere: a solc bump is "run the two scripts, commit". `GeneratedSnapshotShapeTest` replaces the deleted exemplar-versus-reference test. THE ASSERTIONS ARE THE SPECIFICATION — five named properties checked against the compiler's AST, so there is no second file whose provenance has to be defended and no source-text matching that formatting could break: exactly four constants in order with their types, every declaration constant, no `ImportDirective`, no `ContractDefinition`, and the generated-file header. Values are not asserted; a solc change moves every literal without changing the shape, and a wrong literal is already caught by the group 1 derivation checks. Two things about the AST route worth recording. Foundry emits an artifact for a file that declares only file-level constants and no contract at all, which is what makes this possible. And its JSON path support rejects a `$.ast.nodes[*].nodeType` wildcard — a path must resolve to exactly one value — so nodes are indexed one at a time under `vm.keyExistsJson`. `ast = true` in `foundry.toml` puts the AST in the artifacts a plain `forge test` produces, rather than only under an explicit `--ast`. The one awkward step, recorded in `writeSnapshot` and worth upstream fixing: `LibFs.pathForContract` hardcodes `src/generated/` and takes a contract name rather than a path, so a non-default root is reached by generating there and moving the result. Every deploy repo wanting its own generator invocation will hit this. --- CLAUDE.md | 79 ++++---- foundry.toml | 13 +- script/Build.sol | 40 +--- script/BuildTestSnapshots.sol | 40 ++++ src/lib/LibRainDeploySnapshot.sol | 72 ++++++++ test/abstract/MockDeploySuites.sol | 72 ++++---- test/exemplars/0_0_1/MockDeployable.sol | 60 ------ test/exemplars/0_0_2/MockDeployableV2.sol | 60 ------ test/generated/0_0_1/MockDeployable.sol | 25 +++ test/generated/0_0_2/MockDeployableV2.sol | 25 +++ test/src/abstract/RainDeployVerifyChain.t.sol | 75 ++++---- .../abstract/RainDeployVerifyOffline.t.sol | 44 ++--- test/src/lib/GeneratedSnapshotShape.t.sol | 173 +++++++++--------- 13 files changed, 395 insertions(+), 383 deletions(-) create mode 100644 script/BuildTestSnapshots.sol delete mode 100644 test/exemplars/0_0_1/MockDeployable.sol delete mode 100644 test/exemplars/0_0_2/MockDeployableV2.sol create mode 100644 test/generated/0_0_1/MockDeployable.sol create mode 100644 test/generated/0_0_2/MockDeployableV2.sol diff --git a/CLAUDE.md b/CLAUDE.md index 3566db2..78ef193 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,44 +112,47 @@ against it by `AddressRegistryDeployPinsOfflineTest`. GENERATED by `/` snapshot is frozen while the root is a placeholder, because that directory is append-only. -### `test/exemplars/`: the exemplar owns the SHAPE, the compiler owns the VALUES - -`test/exemplars/0_0_1/` and `0_0_2/` look like frozen release snapshots and are -consumed as the recorded triples the verification abstracts check against. They -are **hand-written**, and `GeneratedSnapshotShapeTest` checks the real -generator's committed output — `src/generated/candidate/AddressRegistry.sol` — -against them. - -That independence is the instrument. An exemplar is evidence about the generator -ONLY because the generator did not emit it, exactly as `LibParseSlow` is -evidence about `LibParse` only because it was derived separately. A helper that -regenerated exemplars through `LibCodeGen` and `LibFs` — the emitters -`script/Build.sol` itself uses — would reduce the whole conformance suite to -"the generator is deterministic", which nobody doubted. So there is deliberately -no exemplar generator, and adding one would void the tests. - -The rule resolves every case, and is written into the exemplar files themselves -so it is in front of whoever hits the red: - -- **compiler or optimiser settings moved the VALUES** — update the literals by - hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` fails - with both the stored and the derived value in its message; paste the derived - one. Four literals, and a wrong paste is caught immediately by that same - derivation check — which is why machine-producing them would buy nothing and - cost the conformance test. -- **the generator emitted a different SHAPE by accident** — fix - `script/Build.sol`. Never edit the exemplar to match; it is the thing being - conformed to. -- **you want a different shape deliberately** — change the exemplar and the - generator in one commit. The conformance test proves they agree. - -Shape disputes are settled by the exemplar. Value disputes are settled by the -compiler. They never contest the same thing. - -**`src/lib/LibAddressRegistry.sol`** — reads that registry at its deterministic -address, verifying its code hash first, exactly as `LibRainDeploy` verifies -`ZOLTU_FACTORY_CODEHASH`. It resolves a name to an address and nothing more: -what a consumer resolves a name for, and when, is the consumer's business. +### Generated snapshots, and the assertions that specify their shape + +Every deploy snapshot in this repo is GENERATED and committed. There is no +hand-maintained hex anywhere: + +- `src/generated/candidate/AddressRegistry.sol` — the real deploy record, from + `forge script script/Build.sol`. +- `test/generated/0_0_1/MockDeployable.sol` and `0_0_2/MockDeployableV2.sol` — + the records the verification abstracts are exercised against, from + `forge script script/BuildTestSnapshots.sol`. Under `test/` because + `.soldeerignore` excludes it, so mock records never ship in the package. + +Both scripts call the SAME generator, +`LibRainDeploySnapshot.writeSnapshot(vm, outputRoot, dir, contractName, creationCode)`. +It is parameterised on the declaration and the output root rather than +special-cased, so test records come off the production code path and the shape +assertions describe the emitter that writes real ones. + +A compiler or optimiser change is therefore "run the two scripts, commit". Never +hand-edit a generated file. + +**`GeneratedSnapshotShapeTest` is the specification of the shape.** It asserts +named properties against the compiler's AST — not against a second reference +file, so there is no question of that file's provenance, and not against source +text, so formatting cannot affect it: + +1. exactly four constants, in order: `bytes32 BYTECODE_HASH`, + `address DEPLOYED_ADDRESS`, `bytes CREATION_CODE`, `bytes RUNTIME_CODE` +2. every declaration is `constant` +3. no `ImportDirective` — a snapshot is read by repos that do not have the + contract it describes, which is the whole reason a frozen release stays + verifiable after its source has changed or gone +4. no `ContractDefinition` — it is a record, not code +5. the generated-file header is present + +Values are deliberately not asserted: a solc change moves every literal without +changing anything the shape test is about, and a wrong literal is caught +immediately by the group 1 derivation checks in `RainDeployVerifyOffline`. + +`ast = true` in `foundry.toml` is what puts the AST in the artifacts a plain +`forge test` produces. ### `src/` holds the deploy machinery here. That is a SCOPED EXCEPTION. diff --git a/foundry.toml b/foundry.toml index 82bef7c..aae55d1 100644 --- a/foundry.toml +++ b/foundry.toml @@ -25,15 +25,22 @@ evm_version = "cancun" cbor_metadata = false bytecode_hash = "none" +# GeneratedSnapshotShapeTest asserts the SHAPE of a generated deploy snapshot +# from the compiler's own AST, so the AST has to be in the artifacts that a +# plain `forge test` produces — not only under an explicit `--ast`. +ast = true + # Build reads the version from foundry.toml and writes the generated # per-tag snapshots + the current-pin lib under src/. Nothing else in this repo # touches the filesystem. fs_permissions = [ { access = "read", path = "./foundry.toml" }, { access = "read-write", path = "./src" }, - # GeneratedSnapshotShapeTest reads test/exemplars/ as text to check the - # generator's committed output against a hand-written shape. - { access = "read", path = "./test" }, + # script/BuildTestSnapshots.sol emits the mock snapshots the verification + # abstracts are exercised against. + { access = "read-write", path = "./test" }, + # GeneratedSnapshotShapeTest reads the compiler's AST out of the artifact. + { access = "read", path = "./out" }, ] [dependencies] diff --git a/script/Build.sol b/script/Build.sol index 65b7c47..5cd05f8 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,9 +3,6 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; -import {LibCodeGen} from "rain-sol-codegen-0.1.4/src/lib/LibCodeGen.sol"; -import {LibFs} from "rain-sol-codegen-0.1.4/src/lib/LibFs.sol"; -import {LibRainDeploy} from "../src/lib/LibRainDeploy.sol"; import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; @@ -34,12 +31,6 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; contract Build is Script { string constant GEN_LIB_PATH = "src/lib/LibAddressRegistryDeploy.sol"; - /// @notice The NatSpec emitted above the generated `DEPLOYED_ADDRESS` - /// constant. An argument to `LibCodeGen.addressConstantString` rather than - /// hardcoded into a local copy of it. - string constant DEPLOYED_ADDRESS_COMMENT = - "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory."; - // REUSE-IgnoreStart (the two SPDX lines below are the header EMITTED into the // generated lib, not this script's own license — hide from reuse lint) string constant GEN_SPDX_LICENSE = "// SPDX-License-Identifier: LicenseRef-DCL-1.0"; @@ -70,32 +61,15 @@ contract Build is Script { genLibAddressRegistryDeploy(); } - /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` - /// from what this repo currently compiles. + /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what + /// this repo currently compiles. function regenerateCandidate() internal { - LibRainDeploy.etchZoltuFactory(vm); - - // A fresh checkout has no `candidate/` dir yet, and `vm.writeFile` - // won't create one. - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(LibRainDeploySnapshot.dirForSnapshot(LibRainDeploySnapshot.CANDIDATE), true); - - bytes memory creationCode = type(AddressRegistry).creationCode; - address deployed = LibRainDeploy.deployZoltu(creationCode); - - LibFs.buildFileForContract( + LibRainDeploySnapshot.writeSnapshot( vm, - deployed, - LibRainDeploySnapshot.snapshotName(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"), - string.concat( - LibCodeGen.addressConstantString(vm, DEPLOYED_ADDRESS_COMMENT, "DEPLOYED_ADDRESS", deployed), - LibCodeGen.bytesConstantString( - vm, "/// @dev The creation bytecode of the contract.", "CREATION_CODE", creationCode - ), - LibCodeGen.bytesConstantString( - vm, "/// @dev The runtime bytecode of the contract.", "RUNTIME_CODE", deployed.code - ) - ) + LibRainDeploySnapshot.LIB_FS_ROOT, + LibRainDeploySnapshot.CANDIDATE, + "AddressRegistry", + type(AddressRegistry).creationCode ); } diff --git a/script/BuildTestSnapshots.sol b/script/BuildTestSnapshots.sol new file mode 100644 index 0000000..5fdb662 --- /dev/null +++ b/script/BuildTestSnapshots.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; +import {MockDeployable} from "../test/concrete/MockDeployable.sol"; +import {MockDeployableV2} from "../test/concrete/MockDeployableV2.sol"; + +/// @title BuildTestSnapshots +/// @notice Generates the deploy snapshots the verification abstracts are +/// exercised against, into `test/generated/`. +/// +/// Same generator as `script/Build.sol` — both call +/// `LibRainDeploySnapshot.writeSnapshot`, which is parameterised on the +/// declaration and the output root. This is not a helper, not a reference and +/// not hand-maintained: it is the production code path pointed at test +/// contracts, so `GeneratedSnapshotShapeTest`'s assertions describe the same +/// emitter that writes real deploy records. +/// +/// `test/` is excluded by `.soldeerignore`, so these never ship in the package — +/// which is the whole reason they are not under `src/generated/`. +/// +/// Run as `forge script script/BuildTestSnapshots.sol`. +contract BuildTestSnapshots is Script { + /// @notice Where test snapshots live. + string constant TEST_GENERATED_ROOT = "test/generated"; + + /// @notice Regenerate every test snapshot. Two contracts, so the abstracts + /// see two suites at two different addresses — which is what a repo with a + /// version history actually looks like. + function run() external { + LibRainDeploySnapshot.writeSnapshot( + vm, TEST_GENERATED_ROOT, "0_0_1", "MockDeployable", type(MockDeployable).creationCode + ); + LibRainDeploySnapshot.writeSnapshot( + vm, TEST_GENERATED_ROOT, "0_0_2", "MockDeployableV2", type(MockDeployableV2).creationCode + ); + } +} diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 86e2c4c..29eba29 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -3,7 +3,9 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.1/src/Vm.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.4/src/lib/LibCodeGen.sol"; import {LibFs} from "rain-sol-codegen-0.1.4/src/lib/LibFs.sol"; +import {LibRainDeploy} from "./LibRainDeploy.sol"; /// Thrown when `[package].version` is not strict `X.Y.Z`. A version like /// `0.1.7-rc1` maps to the directory `0_1_7-rc1`, which the append-only gate's @@ -139,6 +141,76 @@ library LibRainDeploySnapshot { return LibFs.pathForContract(snapshotName(dir, contractName)); } + /// The output root `LibFs` writes to, and the only one it can write to: + /// `LibFs.pathForContract` hardcodes it. + string constant LIB_FS_ROOT = "src/generated"; + + /// Generate one snapshot for one contract, under an arbitrary output root. + /// + /// The root is a parameter because a repo generates real deploy records + /// under `src/generated/` and test records under `test/generated/`, and + /// both must come from THIS code path — a second emitter would make the + /// shape assertions a statement about the wrong generator. + /// + /// `LibFs` hardcodes `src/generated/` and takes a contract name rather than + /// a path, so a non-default root is reached by generating there and moving + /// the result. That is the one awkward step here, and it is upstream's to + /// remove: `pathForContract` would need to take a root. + /// @param vm The Vm instance for file operations. + /// @param outputRoot Where the snapshot should end up. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The contract the snapshot describes. + /// @param creationCode That contract's creation code. + /// @return The path written. + function writeSnapshot( + Vm vm, + string memory outputRoot, + string memory dir, + string memory contractName, + bytes memory creationCode + ) internal returns (string memory) { + LibRainDeploy.etchZoltuFactory(vm); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(dirForSnapshot(dir), true); + + address deployed = LibRainDeploy.deployZoltu(creationCode); + + LibFs.buildFileForContract( + vm, + deployed, + snapshotName(dir, contractName), + string.concat( + LibCodeGen.addressConstantString( + vm, + "/// @dev The deterministic deploy address of the contract when deployed via\n/// the Zoltu factory.", + "DEPLOYED_ADDRESS", + deployed + ), + LibCodeGen.bytesConstantString( + vm, "/// @dev The creation bytecode of the contract.", "CREATION_CODE", creationCode + ), + LibCodeGen.bytesConstantString( + vm, "/// @dev The runtime bytecode of the contract.", "RUNTIME_CODE", deployed.code + ) + ) + ); + + string memory written = pathForSnapshot(dir, contractName); + if (keccak256(bytes(outputRoot)) == keccak256(bytes(LIB_FS_ROOT))) { + return written; + } + + string memory destDir = string.concat(outputRoot, "/", dir); + string memory dest = string.concat(destDir, "/", contractName, ".sol"); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(destDir, true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(dest, vm.readFile(written)); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(dirForSnapshot(dir), true); + return dest; + } + /// Regenerate the rolling snapshot and freeze it as this release's record, /// in that order, in one call. /// diff --git a/test/abstract/MockDeploySuites.sol b/test/abstract/MockDeploySuites.sol index 8c5bd25..683419d 100644 --- a/test/abstract/MockDeploySuites.sol +++ b/test/abstract/MockDeploySuites.sol @@ -5,57 +5,49 @@ pragma solidity ^0.8.25; import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; import { - BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../exemplars/0_0_1/MockDeployable.sol"; + BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, + CREATION_CODE as MOCK_CREATION_CODE_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 +} from "../generated/0_0_1/MockDeployable.sol"; import { - BYTECODE_HASH as MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, - CREATION_CODE as MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, - DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, - RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../exemplars/0_0_2/MockDeployableV2.sol"; + BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_2, + CREATION_CODE as MOCK_CREATION_CODE_0_0_2, + DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_2, + RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_2 +} from "../generated/0_0_2/MockDeployableV2.sol"; /// @title MockDeploySuites -/// @notice A deploy repo's suite declaration, as a exemplar: two frozen -/// releases plus a candidate tracking `MockDeployableV2`. +/// @notice A deploy repo's suite declaration, for exercising the verification +/// abstracts: two frozen releases plus a candidate, over two different +/// contracts at two different addresses — which is what a repo with a version +/// history actually looks like. `0_0_2` and the candidate are the same bytes +/// under different keys, so two suites derive one address. /// -/// It is declared once, here, and inherited into one `RainDeployVerifyOffline` -/// contract and one `RainDeployVerifyChain` contract. That is the shape every -/// consumer has, and it is what keeps the two groups in separate contracts -/// without the versions being written out twice. -/// -/// Everything about it is deliberate: -/// -/// - `0_0_1` and `0_0_2` take their creation code from frozen literal -/// constants, never from `type(X).creationCode`. A release records what was -/// deployed; that the contract still exists in this repo is incidental. -/// - `0_0_2` and the candidate are the same bytes, which is what a repo looks -/// like between a release and the next source change. Two suites therefore -/// derive one address, under two distinct keys — each is separately -/// deployable, which is how an old release reaches a chain added after it. -/// - The candidate takes its creation code from source, because it has no -/// frozen snapshot to take it from — the state `AddressRegistry` is in. +/// Both snapshots are REAL generator output, emitted by +/// `script/BuildTestSnapshots.sol` through the same +/// `LibRainDeploySnapshot.writeSnapshot` that writes production deploy records. +/// There is no hand-maintained hex in this repo: a solc bump is +/// `forge script script/BuildTestSnapshots.sol` and commit. abstract contract MockDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { suites = new DeploySuite[](2); suites[0] = DeploySuite({ suite: "mock-deployable-0-0-1", - creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + creationCode: MOCK_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", dependencies: new address[](0) }); suites[1] = DeploySuite({ suite: "mock-deployable-v2-0-0-2", - creationCode: MOCK_DEPLOYABLE_V2_CREATION_CODE_0_0_2, - storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, - storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, - storedRuntimeCode: MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2, + creationCode: MOCK_CREATION_CODE_0_0_2, + storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_2, + storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_2, + storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_2, artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", dependencies: new address[](0) }); @@ -66,10 +58,10 @@ abstract contract MockDeploySuites is RainDeploySuitesBase { return DeployCandidate({ snapshot: DeploySuite({ suite: "mock-deployable-v2-candidate", - creationCode: type(MockDeployableV2).creationCode, - storedDeployedAddress: MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, - storedBytecodeHash: MOCK_DEPLOYABLE_V2_BYTECODE_HASH_0_0_2, - storedRuntimeCode: type(MockDeployableV2).runtimeCode, + creationCode: MOCK_CREATION_CODE_0_0_2, + storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_2, + storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_2, + storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_2, artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", dependencies: new address[](0) }), diff --git a/test/exemplars/0_0_1/MockDeployable.sol b/test/exemplars/0_0_1/MockDeployable.sol deleted file mode 100644 index d0bdee6..0000000 --- a/test/exemplars/0_0_1/MockDeployable.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. -// -// It is committed to the repository because there is a circular dependency -// between the contract and its generated file. The contract needs the -// generated file to exist so that it can compile, and the generated file -// needs the contract to exist so that it can be compiled. -// -// ...except this particular file is not autogenerated. It is a hand-written -// EXEMPLAR of a file that is, carrying that header because reproducing the -// header is part of reproducing the shape. See below. - -// THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. -// -// This file is HAND-WRITTEN, and that is the point. It is the authority on what -// a generated deploy snapshot LOOKS like — which constants exist, of what type, -// in what order, under what header, with no reference to the contract they came -// from — and `GeneratedSnapshotShapeTest` checks the real generator's committed -// output against it. -// -// It is evidence about the generator ONLY because the generator did not produce -// it, exactly as a slow reference implementation is evidence about a fast one -// only when it was derived independently. Regenerate this file from the -// generator and the conformance test collapses into a determinism check that -// passes however far both have drifted. -// -// What to do when something here goes red: -// -// - a compiler or optimiser change moved the VALUES: update the literals below -// by hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` -// fails with both the stored and the derived value in its message — paste the -// derived one. Four literals, and a wrong paste is caught immediately by that -// same derivation check, which is why machine-producing them would buy -// nothing and cost the test. -// - the generator started emitting a DIFFERENT SHAPE by accident: fix -// `script/Build.sol`. Do not touch this file; it is the thing being conformed -// to. -// - you want a different shape deliberately: change this file and the generator -// in one commit. The conformance test proves they agree. -// -// Shape disputes are settled here. Value disputes are settled by the compiler. -// They never contest the same thing. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/exemplars/0_0_2/MockDeployableV2.sol b/test/exemplars/0_0_2/MockDeployableV2.sol deleted file mode 100644 index b690147..0000000 --- a/test/exemplars/0_0_2/MockDeployableV2.sol +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. -// -// It is committed to the repository because there is a circular dependency -// between the contract and its generated file. The contract needs the -// generated file to exist so that it can compile, and the generated file -// needs the contract to exist so that it can be compiled. -// -// ...except this particular file is not autogenerated. It is a hand-written -// EXEMPLAR of a file that is, carrying that header because reproducing the -// header is part of reproducing the shape. See below. - -// THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. -// -// This file is HAND-WRITTEN, and that is the point. It is the authority on what -// a generated deploy snapshot LOOKS like — which constants exist, of what type, -// in what order, under what header, with no reference to the contract they came -// from — and `GeneratedSnapshotShapeTest` checks the real generator's committed -// output against it. -// -// It is evidence about the generator ONLY because the generator did not produce -// it, exactly as a slow reference implementation is evidence about a fast one -// only when it was derived independently. Regenerate this file from the -// generator and the conformance test collapses into a determinism check that -// passes however far both have drifted. -// -// What to do when something here goes red: -// -// - a compiler or optimiser change moved the VALUES: update the literals below -// by hand. `RainDeployVerifyOfflineTest::testDeployPinsInternallyConsistent` -// fails with both the stored and the derived value in its message — paste the -// derived one. Four literals, and a wrong paste is caught immediately by that -// same derivation check, which is why machine-producing them would buy -// nothing and cost the test. -// - the generator started emitting a DIFFERENT SHAPE by accident: fix -// `script/Build.sol`. Do not touch this file; it is the thing being conformed -// to. -// - you want a different shape deliberately: change this file and the generator -// in one commit. The conformance test proves they agree. -// -// Shape disputes are settled here. Value disputes are settled by the compiler. -// They never contest the same thing. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/generated/0_0_1/MockDeployable.sol b/test/generated/0_0_1/MockDeployable.sol new file mode 100644 index 0000000..74b25a5 --- /dev/null +++ b/test/generated/0_0_1/MockDeployable.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +// It is committed to the repository because there is a circular dependency +// between the contract and its generated file. The contract needs the +// generated file to exist so that it can compile, and the generated file +// needs the contract to exist so that it can be compiled. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/generated/0_0_2/MockDeployableV2.sol b/test/generated/0_0_2/MockDeployableV2.sol new file mode 100644 index 0000000..af85817 --- /dev/null +++ b/test/generated/0_0_2/MockDeployableV2.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +// It is committed to the repository because there is a circular dependency +// between the contract and its generated file. The contract needs the +// generated file to exist so that it can compile, and the generated file +// needs the contract to exist so that it can be compiled. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 9ed715d..7f862ea 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -11,14 +11,16 @@ import { import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; import { - BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../exemplars/0_0_1/MockDeployable.sol"; + BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, + CREATION_CODE as MOCK_CREATION_CODE_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 +} from "../../generated/0_0_1/MockDeployable.sol"; import { - DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, - RUNTIME_CODE as MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2 -} from "../../exemplars/0_0_2/MockDeployableV2.sol"; + BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_2, + DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_2, + RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_2 +} from "../../generated/0_0_2/MockDeployableV2.sol"; /// @title RainDeployVerifyChainTest /// @notice `RainDeployVerifyChain` inherited by a exemplar repo whose versions @@ -43,10 +45,10 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// inherited test then verifies. Persistent so it survives each /// `createSelectFork` inside the loop. function setUp() external { - vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); - vm.makePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); - vm.etch(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); - vm.makePersistent(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2); + vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, MOCK_RUNTIME_CODE_0_0_1); + vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); + vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_2, MOCK_RUNTIME_CODE_0_0_2); + vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_2); } /// External wrapper for `checkDeployedOnNetwork` so `vm.expectRevert` works @@ -63,14 +65,14 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// release that therefore never got it, is invisible to every other check. function testChainNotDeployedReverts() external { // Present locally, but no longer carried onto forks. - vm.revokePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); + vm.revokePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); vm.expectRevert( abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + MOCK_DEPLOYED_ADDRESS_0_0_1 ) ); this.testDeployPinsLiveOnEverySupportedNetwork(); @@ -80,14 +82,14 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// reaches. The version missing here is the second and third, so a matrix /// that stopped after the first version would pass. function testChainNotDeployedRevertsForALaterSuite() external { - vm.revokePersistent(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2); + vm.revokePersistent(MOCK_DEPLOYED_ADDRESS_0_0_2); vm.expectRevert( abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, "mock-deployable-v2-0-0-2", - MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2 + MOCK_DEPLOYED_ADDRESS_0_0_2 ) ); this.testDeployPinsLiveOnEverySupportedNetwork(); @@ -127,15 +129,15 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// observed: the wrong code is etched at the address the check reads, so if /// the derivation took its expectation from there this would pass. function testChainCodeHashMismatchReverts() external { - vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, hex"6001"); + vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, hex"6001"); vm.expectRevert( abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + MOCK_DEPLOYED_ADDRESS_0_0_1, + MOCK_BYTECODE_HASH_0_0_1, keccak256(hex"6001") ) ); @@ -151,7 +153,7 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { DerivedDeploy memory derived = DerivedDeploy({ suite: "mock-deployable-0-0-1", - deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + deployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, bytecodeHash: bytes32(uint256(1)) }); @@ -160,9 +162,9 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { CodeHashMismatchOnNetwork.selector, LibRainDeploy.BASE, "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + MOCK_DEPLOYED_ADDRESS_0_0_1, bytes32(uint256(1)), - MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + MOCK_BYTECODE_HASH_0_0_1 ) ); this.externalCheckDeployedOnNetwork(LibRainDeploy.BASE, derived); @@ -180,18 +182,18 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// "deployed over the top" — but a `CREATE2` deploy leaves the account at /// nonce 1, while a restored etch is at nonce 0. function testDerivationRestoresCodeAtDerivedAddress() external { - assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); - assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); - assertEq(vm.getNonce(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1), 0); - assertEq(vm.getNonce(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2), 0); + assertEq(MOCK_DEPLOYED_ADDRESS_0_0_1.code, MOCK_RUNTIME_CODE_0_0_1); + assertEq(MOCK_DEPLOYED_ADDRESS_0_0_2.code, MOCK_RUNTIME_CODE_0_0_2); + assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_1), 0); + assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_2), 0); DerivedDeploy[] memory derived = deriveDeployments(allSuites()); assertEq(derived.length, 3); - assertEq(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1.code, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); - assertEq(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2.code, MOCK_DEPLOYABLE_V2_RUNTIME_CODE_0_0_2); - assertEq(vm.getNonce(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1), 0); - assertEq(vm.getNonce(MOCK_DEPLOYABLE_V2_DEPLOYED_ADDRESS_0_0_2), 0); + assertEq(MOCK_DEPLOYED_ADDRESS_0_0_1.code, MOCK_RUNTIME_CODE_0_0_1); + assertEq(MOCK_DEPLOYED_ADDRESS_0_0_2.code, MOCK_RUNTIME_CODE_0_0_2); + assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_1), 0); + assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_2), 0); } /// The matrix MUST cover every supported network, not a subset one repo @@ -202,25 +204,22 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { string[] memory networks = LibRainDeploy.supportedNetworks(); for (uint256 i = 0; i < networks.length; i++) { // Live on every network except this one. - vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1); - vm.makePersistent(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1); + vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, MOCK_RUNTIME_CODE_0_0_1); + vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); uint256 forkId = vm.createSelectFork(networks[i]); (forkId); - vm.etch(MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, hex""); + vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, hex""); DerivedDeploy memory derived = DerivedDeploy({ suite: "mock-deployable-0-0-1", - deployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - bytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + deployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, + bytecodeHash: MOCK_BYTECODE_HASH_0_0_1 }); vm.expectRevert( abi.encodeWithSelector( - NotDeployedOnNetwork.selector, - networks[i], - "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + NotDeployedOnNetwork.selector, networks[i], "mock-deployable-0-0-1", MOCK_DEPLOYED_ADDRESS_0_0_1 ) ); this.externalCheckDeployedOnNetwork(networks[i], derived); diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifyOffline.t.sol index 128bc01..0ad3cb1 100644 --- a/test/src/abstract/RainDeployVerifyOffline.t.sol +++ b/test/src/abstract/RainDeployVerifyOffline.t.sol @@ -16,11 +16,11 @@ import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { - BYTECODE_HASH as MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - CREATION_CODE as MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1 -} from "../../exemplars/0_0_1/MockDeployable.sol"; + BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, + CREATION_CODE as MOCK_CREATION_CODE_0_0_1, + DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, + RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 +} from "../../generated/0_0_1/MockDeployable.sol"; /// @title RainDeployVerifyOfflineTest /// @notice `RainDeployVerifyOffline` inherited by a exemplar repo, so the @@ -60,10 +60,10 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin return DeployCandidate({ snapshot: DeploySuite({ suite: "mock-deployable-v2-candidate", - creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + creationCode: MOCK_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", dependencies: new address[](0) }), @@ -77,10 +77,10 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin function consistentSuite() internal pure returns (DeploySuite memory) { return DeploySuite({ suite: "mock-deployable-0-0-1", - creationCode: MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_DEPLOYABLE_RUNTIME_CODE_0_0_1, + creationCode: MOCK_CREATION_CODE_0_0_1, + storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, + storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, + storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", dependencies: new address[](0) }); @@ -95,10 +95,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( - StoredAddressMismatch.selector, - "mock-deployable-0-0-1", - address(0xdead), - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1 + StoredAddressMismatch.selector, "mock-deployable-0-0-1", address(0xdead), MOCK_DEPLOYED_ADDRESS_0_0_1 ) ); this.externalCheckInternallyConsistent(suite); @@ -113,10 +110,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( - StoredCodeHashMismatch.selector, - "mock-deployable-0-0-1", - bytes32(uint256(1)), - MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1 + StoredCodeHashMismatch.selector, "mock-deployable-0-0-1", bytes32(uint256(1)), MOCK_BYTECODE_HASH_0_0_1 ) ); this.externalCheckInternallyConsistent(suite); @@ -135,7 +129,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin abi.encodeWithSelector( StoredRuntimeCodeHashMismatch.selector, "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_BYTECODE_HASH_0_0_1, + MOCK_BYTECODE_HASH_0_0_1, keccak256(hex"00") ) ); @@ -172,7 +166,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin abi.encodeWithSelector( CandidateSourceMismatch.selector, "mock-deployable-v2-candidate", - keccak256(MOCK_DEPLOYABLE_CREATION_CODE_0_0_1), + keccak256(MOCK_CREATION_CODE_0_0_1), keccak256(type(MockDeployableV2).creationCode) ) ); @@ -215,7 +209,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin // the one the creation code derives. vm.mockCall( LibRainDeploy.ZOLTU_FACTORY, - MOCK_DEPLOYABLE_CREATION_CODE_0_0_1, + MOCK_CREATION_CODE_0_0_1, abi.encodePacked(bytes20(LibRainDeploy.ZOLTU_FACTORY)) ); @@ -223,7 +217,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin abi.encodeWithSelector( ZoltuDerivationMismatch.selector, "mock-deployable-0-0-1", - MOCK_DEPLOYABLE_DEPLOYED_ADDRESS_0_0_1, + MOCK_DEPLOYED_ADDRESS_0_0_1, LibRainDeploy.ZOLTU_FACTORY ) ); diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index 68b60f3..19ae7e2 100644 --- a/test/src/lib/GeneratedSnapshotShape.t.sol +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -5,55 +5,68 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; /// @title GeneratedSnapshotShapeTest -/// @notice THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES. This is -/// what makes the first half true rather than aspirational. +/// @notice What a generated deploy snapshot must look like, asserted against +/// the real generator's committed output. /// -/// It checks the REAL generator's committed output — -/// `src/generated/candidate/AddressRegistry.sol`, written by -/// `script/Build.sol` — against `test/exemplars/`, which is hand-written. +/// THESE ASSERTIONS ARE THE SPECIFICATION. There is no second hand-written file +/// to compare against and therefore no question about that file's provenance: +/// "there is an `address` constant named `DEPLOYED_ADDRESS`" is stated here, +/// once, in test code, and checked against what `script/Build.sol` actually +/// emitted. /// -/// The independence is the whole instrument. The exemplar is evidence about the -/// generator precisely because the generator did not emit it: a helper that -/// regenerated exemplars through `LibCodeGen` and `LibFs` — the emitters -/// `Build.sol` itself uses — would reduce every assertion below to "the -/// generator is deterministic", which nobody doubted. So the exemplars are -/// maintained by hand and this reads both files as text. +/// The check reads the compiler's AST rather than the source text, so it is +/// about the file's STRUCTURE and not its formatting. `forge build --ast` +/// writes the AST into the artifact JSON, and — usefully, since a snapshot +/// declares only file-level constants and no contract at all — foundry still +/// emits an artifact for such a file. /// -/// It asserts NAMED STRUCTURAL PROPERTIES rather than diffing whole files. A -/// diff fails for reasons nobody can read, and one exemplar compared once is -/// already a weak instrument; naming each property means a failure says which -/// one broke. Values are deliberately not compared — the two files describe -/// different contracts, and a solc bump moves every literal in both without -/// changing anything this test is about. +/// Values are deliberately not asserted. A solc or optimiser change moves every +/// literal without changing anything here, and a wrong literal is caught +/// immediately by the group 1 derivation checks in `RainDeployVerifyOffline`. contract GeneratedSnapshotShapeTest is Test { - /// The real generator's output, as committed. - /// @return The file contents. - function generated() internal view returns (string memory) { - return vm.readFile("src/generated/candidate/AddressRegistry.sol"); - } + /// The artifact for the generated candidate snapshot, which carries its AST. + string constant ARTIFACT = "out/candidate/AddressRegistry.sol/AddressRegistry.json"; - /// The hand-written exemplar. - /// @return The file contents. - function exemplar() internal view returns (string memory) { - return vm.readFile("test/exemplars/0_0_1/MockDeployable.sol"); + /// The node types of the generated snapshot's source unit, in file order. + /// + /// Indexed one at a time rather than with a `$.ast.nodes[*].nodeType` + /// wildcard: foundry's JSON path support requires a path to resolve to + /// exactly one value, so the wildcard is rejected outright. + /// @return types One entry per top-level AST node. + function nodeTypes() internal view returns (string[] memory types) { + string memory json = vm.readFile(ARTIFACT); + string[] memory found = new string[](64); + uint256 count = 0; + while (vm.keyExistsJson(json, string.concat("$.ast.nodes[", vm.toString(count), "].nodeType"))) { + found[count] = vm.parseJsonString(json, string.concat("$.ast.nodes[", vm.toString(count), "].nodeType")); + count++; + } + types = new string[](count); + for (uint256 i = 0; i < count; i++) { + types[i] = found[i]; + } } - /// The ordered constant DECLARATIONS in a file, values stripped: - /// `bytes32 constant BYTECODE_HASH` and so on. This is the shape. - /// @param content The file to read. - /// @return declarations One entry per constant, in file order. - function constantDeclarations(string memory content) internal pure returns (string[] memory declarations) { - string[] memory lines = vm.split(content, "\n"); - string[] memory found = new string[](lines.length); + /// The declared constants of the generated snapshot, in file order, as + /// ` ` — read from the AST, so formatting cannot affect it. + /// @return declarations One entry per file-level constant. + function constantDeclarations() internal view returns (string[] memory declarations) { + string memory json = vm.readFile(ARTIFACT); + string[] memory types = nodeTypes(); + + string[] memory found = new string[](types.length); uint256 count = 0; - for (uint256 i = 0; i < lines.length; i++) { - // A declaration line, not a comment describing one. - if (!vm.contains(lines[i], " constant ") || vm.contains(lines[i], "//")) { + for (uint256 i = 0; i < types.length; i++) { + if (keccak256(bytes(types[i])) != keccak256(bytes("VariableDeclaration"))) { continue; } - // Everything before the assignment is the declaration; everything - // after it is a value, which this test has no opinion about. - found[count] = vm.split(lines[i], " =")[0]; + string memory base = string.concat("$.ast.nodes[", vm.toString(i), "]"); + assertTrue(vm.parseJsonBool(json, string.concat(base, ".constant")), "snapshot declared a non-constant"); + found[count] = string.concat( + vm.parseJsonString(json, string.concat(base, ".typeName.name")), + " ", + vm.parseJsonString(json, string.concat(base, ".name")) + ); count++; } declarations = new string[](count); @@ -62,62 +75,50 @@ contract GeneratedSnapshotShapeTest is Test { } } - /// PROPERTY: the generator emits exactly the four constants a deploy - /// snapshot is for, in this order. Named literally, because the exemplar's - /// authority comes from a human having written down what a snapshot SHOULD - /// be — not from whatever the generator currently does. - function testGeneratorEmitsTheDeploySnapshotConstantsInOrder() external view { - string[] memory declarations = constantDeclarations(generated()); + /// PROPERTY: the snapshot declares exactly the four constants a deploy + /// record is for, of these types, in this order. A rename, a retype, a + /// reorder or a fifth constant each fail here and name what broke. + function testSnapshotDeclaresTheFourDeployConstantsInOrder() external view { + string[] memory declarations = constantDeclarations(); - assertEq(declarations.length, 4, "generator emitted an unexpected number of constants"); - assertEq(declarations[0], "bytes32 constant BYTECODE_HASH"); - assertEq(declarations[1], "address constant DEPLOYED_ADDRESS"); - assertEq(declarations[2], "bytes constant CREATION_CODE"); - assertEq(declarations[3], "bytes constant RUNTIME_CODE"); + assertEq(declarations.length, 4, "snapshot declares an unexpected number of constants"); + assertEq(declarations[0], "bytes32 BYTECODE_HASH", "first constant is not bytes32 BYTECODE_HASH"); + assertEq(declarations[1], "address DEPLOYED_ADDRESS", "second constant is not address DEPLOYED_ADDRESS"); + assertEq(declarations[2], "bytes CREATION_CODE", "third constant is not bytes CREATION_CODE"); + assertEq(declarations[3], "bytes RUNTIME_CODE", "fourth constant is not bytes RUNTIME_CODE"); } - /// PROPERTY: the exemplar declares the same constants, of the same types, - /// in the same order, as the generator's real output. This is the - /// conformance itself — a fifth constant, a rename, a reorder or a changed - /// type breaks it, and none of those is something a compiler can cause. - function testExemplarDeclaresWhatTheGeneratorEmits() external view { - string[] memory fromGenerator = constantDeclarations(generated()); - string[] memory fromExemplar = constantDeclarations(exemplar()); - - assertEq(fromExemplar.length, fromGenerator.length, "exemplar and generator disagree on constant count"); - for (uint256 i = 0; i < fromGenerator.length; i++) { - assertEq(fromExemplar[i], fromGenerator[i]); + /// PROPERTY: the snapshot imports nothing. It is read by repos that do not + /// have the contract it describes — that is the whole reason a frozen + /// release stays verifiable after its source has changed or gone — so any + /// import would make it unusable to exactly the readers it exists for. + function testSnapshotImportsNothing() external view { + string[] memory types = nodeTypes(); + for (uint256 i = 0; i < types.length; i++) { + assertNotEq(types[i], "ImportDirective", "snapshot imports something"); } } - /// PROPERTY: both carry the generated-file header. It is what tells a - /// reader the file is not to be hand-edited, and an exemplar without it - /// would be describing something the generator does not produce. - function testBothCarryTheGeneratedHeader() external view { - string memory header = "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."; - assertTrue(vm.contains(generated(), header), "generator stopped emitting the header"); - assertTrue(vm.contains(exemplar(), header), "exemplar is missing the generated header"); - } - - /// PROPERTY: a snapshot references no source contract. It is read by repos - /// that do not have that source — which is the whole reason a frozen - /// release stays verifiable after its contract has changed or gone — so an - /// import, or the contract's own name, would make it unusable. - function testNeitherReferencesASourceContract() external view { - assertFalse(vm.contains(generated(), "import "), "generator emitted an import"); - assertFalse(vm.contains(generated(), "AddressRegistry"), "generator referenced its source contract"); - - assertFalse(vm.contains(exemplar(), "import "), "exemplar carries an import"); - assertFalse(vm.contains(exemplar(), "MockDeployable"), "exemplar references a source contract"); + /// PROPERTY: the snapshot declares no contract, library or interface. It is + /// a record, not code; anything deployable in it would be a second + /// definition of something the record is supposed to describe. + function testSnapshotDeclaresNoContract() external view { + string[] memory types = nodeTypes(); + for (uint256 i = 0; i < types.length; i++) { + assertNotEq(types[i], "ContractDefinition", "snapshot declares a contract"); + } } - /// PROPERTY: the exemplar carries the operating rule, because the rule is - /// what someone reads when this suite goes red. - function testExemplarCarriesTheOperatingRule() external view { + /// PROPERTY: the snapshot says it is generated. Someone who opens it must + /// be told not to hand-edit it, because hand-editing is how a deploy record + /// stops describing the deployment. + function testSnapshotSaysItIsGenerated() external view { assertTrue( - vm.contains(exemplar(), "THE EXEMPLAR OWNS THE SHAPE. THE COMPILER OWNS THE VALUES."), - "exemplar is missing the operating rule" + vm.contains( + vm.readFile("src/generated/candidate/AddressRegistry.sol"), + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND." + ), + "snapshot is missing the generated-file header" ); - assertTrue(vm.contains(exemplar(), "This file is HAND-WRITTEN"), "exemplar no longer states it is hand-written"); } } From e7b038482045dba17b4e1e16d2d447e0bf68bb68 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 18:38:19 +0000 Subject: [PATCH 15/29] refactor(snapshot): share the alias-lib emitter, and stop swallowing two failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all in the machinery every other deploy repo copies. **The alias lib is emitted once, not per repo.** `LibRainDeploySnapshot.writeAliasLib(vm, contractName, constantPrefix, dir)` replaces ~20 lines of `vm.writeLine` in `script/Build.sol`. `rain.factory.deploy`'s `LibCloneFactoryDeploy` is this shape to the character, so it was a precedent for copy-and-drift. The library name and output path are DERIVED (`LibDeploy` at `src/lib/`, mechanical); the constant prefix is PASSED, because deriving `ADDRESS_REGISTRY` from `AddressRegistry` means camelCase-to-SCREAMING_SNAKE in Solidity — a byte loop with an acronym problem — to save a caller one short string. It lives beside the rest of the snapshot machinery because that is what it is: the stable import path naming which snapshot is current. `st0x.deploy`'s `LibProdDeployV4` aggregates many contracts and is NOT this shape; the shared emitter covers the one-contract case that three repos have. **`filePrefix` is still not used, and the doc comment no longer claims it is.** It hardcodes a paragraph about a circular dependency between a contract and its generated file — true of a snapshot, false of an alias lib, which is committed because it IS the source consumers import. Emitting it would put a false statement into generated output. So `writeAliasLib` owns its header and carries the REUSE ignore, and `Build.sol` no longer says "nothing here restates any of them" while restating the SPDX lines. Upstream fix recorded on the function: split `filePrefix` into the invariant part and a caller-supplied rationale, or take that rationale as a parameter. **A failed `revertToState` is now loud.** `(reverted);` discarded it. Since `deriveDeployments` loops, a false return leaked the etch and the nonce reset into every later derivation, which would then read state the previous iteration planted and produce entirely plausible results. `DerivationSnapshotRevertFailed` names the suite. The existing test catches a DELETED revert, not one that runs and fails; there is no safe way to continue, so this reverts. **Staging no longer risks a real release.** Generating to a non-default root stages through `src/generated/` and then recursively removes it. That removal is under the directory frozen releases live in, gated only by a string comparison. It now refuses to stage through a directory that already exists, so a `dir` colliding with a real frozen tag fails loudly instead of deleting it. The clean fix is upstream and named on the function: `LibFs.pathForContract(string root, string contractName)` with `buildFileForContract` passing it through removes the staging, the copy and the removal entirely. --- script/Build.sol | 55 ++---------- src/abstract/RainDeployVerifyBase.sol | 21 +++-- src/lib/LibRainDeploySnapshot.sol | 123 +++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 55 deletions(-) diff --git a/script/Build.sol b/script/Build.sol index 5cd05f8..a59416d 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -25,23 +25,19 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// record — what each release actually deployed — which is what /// `AddressRegistryDeploySuites.releasedSuites()` enumerates. /// -/// The tag, both snapshot paths and the freeze come from -/// `LibRainDeploySnapshot`; every constant is emitted by `LibCodeGen`; the file -/// itself is written by `LibFs`. Nothing here restates any of them. +/// The tag, both snapshot paths, the freeze, the snapshot writer and the alias +/// lib writer all come from `LibRainDeploySnapshot`, which in turn emits every +/// constant through `LibCodeGen` and writes snapshots through `LibFs`. This +/// script is the declaration and the sequencing, nothing else. contract Build is Script { - string constant GEN_LIB_PATH = "src/lib/LibAddressRegistryDeploy.sol"; - - // REUSE-IgnoreStart (the two SPDX lines below are the header EMITTED into the - // generated lib, not this script's own license — hide from reuse lint) - string constant GEN_SPDX_LICENSE = "// SPDX-License-Identifier: LicenseRef-DCL-1.0"; - string constant GEN_SPDX_COPYRIGHT = "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd"; - - // REUSE-IgnoreEnd + /// @notice The prefix for the constants the alias lib exports. Passed + /// rather than derived from the contract name; see `writeAliasLib`. + string constant CONSTANT_PREFIX = "ADDRESS_REGISTRY"; /// @notice Every build: regenerate the rolling snapshot and its alias lib. function run() external { regenerateCandidate(); - genLibAddressRegistryDeploy(); + LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); } /// @notice A release: regenerate the rolling snapshot, then freeze it as @@ -58,7 +54,7 @@ contract Build is Script { string[] memory contractNames = new string[](1); contractNames[0] = "AddressRegistry"; LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); - genLibAddressRegistryDeploy(); + LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); } /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what @@ -72,37 +68,4 @@ contract Build is Script { type(AddressRegistry).creationCode ); } - - /// @notice (Re)generate `src/lib/LibAddressRegistryDeploy.sol`, aliasing the - /// ROLLING candidate snapshot's `DEPLOYED_ADDRESS` + `BYTECODE_HASH` as the - /// current constants — that snapshot stays the single source of truth - /// (never a duplicated literal), and the import path never moves because - /// `candidate` never moves. Emitted line-by-line to match the - /// generated-file convention. - function genLibAddressRegistryDeploy() internal { - string memory importPath = - string.concat("../generated/", LibRainDeploySnapshot.CANDIDATE, "/AddressRegistry.sol"); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, GEN_SPDX_LICENSE); - vm.writeLine(GEN_LIB_PATH, GEN_SPDX_COPYRIGHT); - vm.writeLine(GEN_LIB_PATH, "pragma solidity ^0.8.25;"); - vm.writeLine(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."); - vm.writeLine(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, "import {"); - vm.writeLine(GEN_LIB_PATH, " DEPLOYED_ADDRESS as ADDRESS_REGISTRY_ADDR,"); - vm.writeLine(GEN_LIB_PATH, " BYTECODE_HASH as ADDRESS_REGISTRY_HASH"); - vm.writeLine(GEN_LIB_PATH, string.concat("} from \"", importPath, "\";")); - vm.writeLine(GEN_LIB_PATH, ""); - vm.writeLine(GEN_LIB_PATH, "/// @title LibAddressRegistryDeploy"); - vm.writeLine(GEN_LIB_PATH, "/// @notice The deterministic Zoltu deploy address and code hash of"); - vm.writeLine(GEN_LIB_PATH, "/// `AddressRegistry` as this repo currently compiles it, aliased from the"); - vm.writeLine(GEN_LIB_PATH, "/// rolling `src/generated/candidate/AddressRegistry.sol` snapshot so"); - vm.writeLine(GEN_LIB_PATH, "/// that snapshot stays the single source of truth."); - vm.writeLine(GEN_LIB_PATH, "library LibAddressRegistryDeploy {"); - vm.writeLine(GEN_LIB_PATH, " address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR;"); - vm.writeLine(GEN_LIB_PATH, " bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH;"); - vm.writeLine(GEN_LIB_PATH, "}"); - } } diff --git a/src/abstract/RainDeployVerifyBase.sol b/src/abstract/RainDeployVerifyBase.sol index 267a5fc..80f4c66 100644 --- a/src/abstract/RainDeployVerifyBase.sol +++ b/src/abstract/RainDeployVerifyBase.sol @@ -17,6 +17,15 @@ import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; /// @param factoryAddress The address the factory bytecode actually deployed to. error ZoltuDerivationMismatch(string suite, address formulaAddress, address factoryAddress); +/// Thrown when the state snapshot taken around a derivation could not be +/// reverted. The derivation plants code at the derived address and clears its +/// nonce; if that cannot be undone, every later derivation reads state this one +/// created, and the chain-anchored checks compare a locally planted deployment +/// against itself. There is no safe way to continue. +/// @param suite The suite being derived when the revert failed. +/// @param snapshotId The snapshot that could not be reverted. +error DerivationSnapshotRevertFailed(string suite, uint256 snapshotId); + /// What a suite's creation code derives, offline and by itself. Computed /// once and then compared against whatever claims to hold it, whether that is a /// recorded constant or a live chain. @@ -101,11 +110,13 @@ abstract contract RainDeployVerifyBase is RainDeploySuitesBase, Test { derived = DerivedDeploy({suite: suite.suite, deployedAddress: formulaAddress, bytecodeHash: factoryAddress.codehash}); - // revertToState returns whether the snapshot existed; it was taken - // above, so bind and reference it to satisfy the unused-return lint - // rather than asserting on it. - bool reverted = vm.revertToState(snapshotId); - (reverted); + // A failed revert is unrecoverable, not a warning to silence. The etch + // and the nonce reset would survive into every later derivation, whose + // results would then look entirely plausible while describing state + // this call planted. + if (!vm.revertToState(snapshotId)) { + revert DerivationSnapshotRevertFailed(suite.suite, snapshotId); + } } /// Derives every suite once, before anything forks. Callers that compare diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 29eba29..3a8b024 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -27,6 +27,14 @@ error NothingToFreeze(string path); /// @param dir The frozen directory that already exists. error SnapshotAlreadyFrozen(string tag, string dir); +/// Thrown when generating to a non-default output root would have to stage +/// through a `src/generated/` directory that already exists. Staging removes +/// that directory afterwards, so proceeding would recursively delete content +/// this call did not create — a real frozen release, if the name collided. +/// @param dir The colliding directory. +/// @param path The staging path under `src/generated/`. +error SnapshotScratchDirCollision(string dir, string path); + /// @title LibRainDeploySnapshot /// @notice Which release is being built, where its record lives, and how it is /// frozen. Release machinery, not code generation. @@ -153,9 +161,19 @@ library LibRainDeploySnapshot { /// shape assertions a statement about the wrong generator. /// /// `LibFs` hardcodes `src/generated/` and takes a contract name rather than - /// a path, so a non-default root is reached by generating there and moving - /// the result. That is the one awkward step here, and it is upstream's to - /// remove: `pathForContract` would need to take a root. + /// a path, so a non-default root is reached by STAGING there and moving the + /// result, then removing the staging directory. That recursive removal is + /// the sharp edge: it is under `src/generated/`, where real frozen releases + /// live. It is guarded by refusing to stage through a directory that + /// already exists, so a name collision fails loudly instead of deleting a + /// release. + /// + /// The guard exists because the clean fix is upstream and not ours: + /// `LibFs.pathForContract` needs to take an output root + /// (`pathForContract(string root, string contractName)`), with + /// `buildFileForContract` passing it through. Then a caller writes directly + /// to `test/generated/` and there is no staging, no copy and no removal at + /// all. /// @param vm The Vm instance for file operations. /// @param outputRoot Where the snapshot should end up. /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. @@ -169,6 +187,14 @@ library LibRainDeploySnapshot { string memory contractName, bytes memory creationCode ) internal returns (string memory) { + bool staging = keccak256(bytes(outputRoot)) != keccak256(bytes(LIB_FS_ROOT)); + // Staging ends by removing the directory, so it must not begin with one + // that already exists. A test snapshot whose `dir` collided with a real + // frozen tag would otherwise destroy it. + if (staging && vm.exists(dirForSnapshot(dir))) { + revert SnapshotScratchDirCollision(dir, dirForSnapshot(dir)); + } + LibRainDeploy.etchZoltuFactory(vm); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dirForSnapshot(dir), true); @@ -196,7 +222,7 @@ library LibRainDeploySnapshot { ); string memory written = pathForSnapshot(dir, contractName); - if (keccak256(bytes(outputRoot)) == keccak256(bytes(LIB_FS_ROOT))) { + if (!staging) { return written; } @@ -211,6 +237,95 @@ library LibRainDeploySnapshot { return dest; } + /// Generate the alias lib for a snapshot: the stable, consumer-facing + /// import path that re-exports one snapshot's address and code hash. + /// + /// Every deploy repo needs exactly this file and only four things differ, + /// three of which follow from the first — `rain.factory.deploy`'s + /// `LibCloneFactoryDeploy` is this shape to the character. Emitted here so + /// that ~20 lines of `vm.writeLine` are not copied into every repo and then + /// drifted. + /// + /// The constant prefix is passed rather than derived. Deriving + /// `ADDRESS_REGISTRY` from `AddressRegistry` means camelCase to + /// SCREAMING_SNAKE in Solidity, which is a byte loop with an acronym + /// problem, to save a caller one short string. The library name and output + /// path ARE derived, because `LibDeploy` at `src/lib/` is + /// mechanical. + /// + /// ## This owns its header, and `LibCodeGen.filePrefix` cannot supply it + /// + /// `filePrefix` hardcodes a paragraph explaining that the file is committed + /// because of a circular dependency between a contract and its generated + /// file. That is true of a snapshot and FALSE of an alias lib, which is + /// committed because it IS the stable source consumers import. Emitting it + /// here would put a false statement into generated output, so the header is + /// written out instead — the SPDX lines included, which is why this + /// function carries a REUSE ignore. + /// + /// The upstream fix is to split `filePrefix` into the invariant part (SPDX, + /// pragma, `AUTOGENERATED ... DO NOT EDIT BY HAND`) and a caller-supplied + /// rationale, or to take that rationale as a parameter. Then both this and + /// the snapshot writer use it and nothing here restates anything. + /// @param vm The Vm instance for file operations. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants, e.g. + /// `ADDRESS_REGISTRY`. + /// @param dir The snapshot directory to alias — ordinarily `CANDIDATE`. + /// @return The path written. + function writeAliasLib(Vm vm, string memory contractName, string memory constantPrefix, string memory dir) + internal + returns (string memory) + { + string memory libraryName = string.concat("Lib", contractName, "Deploy"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + string memory importPath = string.concat("../generated/", dir, "/", contractName, ".sol"); + + // REUSE-IgnoreStart (the SPDX lines below are the header EMITTED into + // the generated lib, not this file's own license) + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + path, + string.concat( + "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + "import {\n", + " DEPLOYED_ADDRESS as ", + constantPrefix, + "_ADDR,\n BYTECODE_HASH as ", + constantPrefix, + "_HASH\n} from \"", + importPath, + "\";\n\n", + "/// @title ", + libraryName, + "\n/// @notice The deterministic Zoltu deploy address and code hash of\n/// `", + contractName, + "`, aliased from the `src/generated/", + dir, + "/", + contractName, + ".sol`\n/// snapshot so that snapshot stays the single source of truth. The import\n", + "/// path never moves, so consumers are unaffected by which snapshot it names.\n", + "library ", + libraryName, + " {\n address constant ", + constantPrefix, + "_DEPLOYED_ADDRESS = ", + constantPrefix, + "_ADDR;\n bytes32 constant ", + constantPrefix, + "_DEPLOYED_CODEHASH = ", + constantPrefix, + "_HASH;\n}\n" + ) + ); + // REUSE-IgnoreEnd + return path; + } + /// Regenerate the rolling snapshot and freeze it as this release's record, /// in that order, in one call. /// From f7e0fe5b94f376bb227c0c36fc4ebb4ff0494973 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 18:49:30 +0000 Subject: [PATCH 16/29] test(snapshot): prove the release guards fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LibRainDeploySnapshot` had four error paths and no tests. Guards only run when something has already gone wrong, so nothing else reaches them — and a guard nobody has seen fire is a guard nobody knows works. `deployTag` is split so the strict `X.Y.Z` refusal is reachable at all: `tagForVersion(string)` holds the guard and the conversion, `deployTag(vm)` is the thin `foundry.toml` read on top. Before this the guard could only be exercised by writing a `foundry.toml`, which is why it had never been. Six tests: the conversion; nine non-strict versions refused, each naming itself (`0.1.7-rc1`, `0.1`, `0.1.7.1`, empty, `a.b.c`, leading/trailing/double dot, trailing space); `deployTag` going through the same guard, so a repo cannot reach a release path with a version the guard would refuse; the snapshot paths agreeing with the `LibFs` writer that produces them. And both sides of the staging guard, which is the sharp one — staging removes a directory under `src/generated/`, where frozen releases live. It refuses an existing directory and leaves it intact, while the default root, which does not stage, still regenerates over an existing directory as it must. --- src/lib/LibRainDeploySnapshot.sol | 22 +++-- test/src/lib/LibRainDeploySnapshot.t.sol | 108 +++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 test/src/lib/LibRainDeploySnapshot.t.sol diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 3a8b024..0123f1d 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -81,17 +81,27 @@ library LibRainDeploySnapshot { /// form. The single definition of the tag form — the version in /// `foundry.toml` is the one source of truth for which release is being /// built, so every path derives from it rather than restating it. - /// - /// Refuses anything that is not strict `X.Y.Z`. /// @param vm The Vm instance for file operations. /// @return The tag. function deployTag(Vm vm) internal view returns (string memory) { - string memory version = vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version"); + return tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")); + } + + /// The directory form of a release version, refusing anything that is not + /// strict `X.Y.Z`. + /// + /// Split from `deployTag` so the refusal is reachable without writing a + /// `foundry.toml`: a guard that cannot be exercised is a guard nobody knows + /// works. `0.1.7-rc1` maps to `0_1_7-rc1`, a directory the append-only gate + /// ignores forever — an orphan snapshot nothing protects — so it is refused + /// rather than frozen. + /// @param version The version string, e.g. `0.1.7`. + /// @return The tag, e.g. `0_1_7`. + function tagForVersion(string memory version) internal pure returns (string memory) { bytes memory versionBytes = bytes(version); - // Strict X.Y.Z: digits and exactly two dots, no leading or trailing - // dot, no empty component. Checked here rather than by the caller - // because every path below derives from the result. + // Digits and exactly two dots, no leading or trailing dot, no empty + // component. uint256 dots = 0; uint256 digitsInComponent = 0; for (uint256 i = 0; i < versionBytes.length; i++) { diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol new file mode 100644 index 0000000..2a08248 --- /dev/null +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -0,0 +1,108 @@ +// 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 { + LibRainDeploySnapshot, + SnapshotScratchDirCollision, + UnreleasableVersion +} from "../../../src/lib/LibRainDeploySnapshot.sol"; +import {MockDeployable} from "../../concrete/MockDeployable.sol"; + +/// @title LibRainDeploySnapshotTest +/// @notice The guards on the release machinery every deploy repo inherits. +/// +/// These are the paths that only run when something has already gone wrong, so +/// nothing else exercises them — and a guard nobody has seen fire is a guard +/// nobody knows works. Each is driven here directly. +contract LibRainDeploySnapshotTest is Test { + /// External wrapper so `vm.expectRevert` lands at the right call depth. + /// @param version The version to convert. + /// @return The tag. + function externalTagForVersion(string memory version) external pure returns (string memory) { + return LibRainDeploySnapshot.tagForVersion(version); + } + + /// External wrapper so `vm.expectRevert` lands at the right call depth. + /// @param outputRoot Where the snapshot should end up. + /// @param dir The snapshot directory. + /// @return The path written. + function externalWriteSnapshot(string memory outputRoot, string memory dir) external returns (string memory) { + return + LibRainDeploySnapshot.writeSnapshot( + vm, outputRoot, dir, "MockDeployable", type(MockDeployable).creationCode + ); + } + + /// A strict `X.Y.Z` version MUST become its directory form. + function testTagForVersionConvertsDots() external pure { + assertEq(LibRainDeploySnapshot.tagForVersion("0.1.7"), "0_1_7"); + assertEq(LibRainDeploySnapshot.tagForVersion("10.20.30"), "10_20_30"); + assertEq(LibRainDeploySnapshot.tagForVersion("0.0.0"), "0_0_0"); + } + + /// Anything that is not strict `X.Y.Z` MUST be refused rather than mapped + /// to a directory the append-only gate ignores forever. + function testTagForVersionRefusesNonStrict() external { + string[9] memory bad = ["0.1.7-rc1", "0.1", "0.1.7.1", "", "a.b.c", ".1.7", "0..7", "0.1.", "0.1.7 "]; + for (uint256 i = 0; i < bad.length; i++) { + vm.expectRevert(abi.encodeWithSelector(UnreleasableVersion.selector, bad[i])); + this.externalTagForVersion(bad[i]); + } + } + + /// The tag read from `foundry.toml` MUST go through the same guard, so a + /// repo cannot reach a release path with a version the guard would refuse. + function testDeployTagUsesTheGuardedConversion() external view { + assertEq( + LibRainDeploySnapshot.deployTag(vm), + LibRainDeploySnapshot.tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")) + ); + } + + /// Snapshot paths MUST agree with `LibFs`, which is what writes them. + function testSnapshotPathsAgreeWithTheWriter() external pure { + assertEq(LibRainDeploySnapshot.dirForSnapshot("0_1_7"), "src/generated/0_1_7"); + assertEq(LibRainDeploySnapshot.snapshotName("0_1_7", "Foo"), "0_1_7/Foo"); + assertEq(LibRainDeploySnapshot.pathForSnapshot("0_1_7", "Foo"), "src/generated/0_1_7/Foo.sol"); + } + + /// Generating to a non-default root stages through `src/generated/` + /// and REMOVES it afterwards. So it MUST refuse a directory that already + /// exists: that removal is under the directory frozen releases live in, and + /// a colliding `dir` would destroy one. + function testWriteSnapshotRefusesAnExistingScratchDir() external { + string memory dir = "collision-guard"; + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + + vm.expectRevert( + abi.encodeWithSelector(SnapshotScratchDirCollision.selector, dir, LibRainDeploySnapshot.dirForSnapshot(dir)) + ); + this.externalWriteSnapshot("test/generated", dir); + + // The guard must leave it alone, not remove it. + assertTrue(vm.exists(LibRainDeploySnapshot.dirForSnapshot(dir))); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + } + + /// Generating to the DEFAULT root does not stage, so it MUST NOT refuse an + /// existing directory — that is the ordinary case of regenerating a + /// snapshot that is already there. + function testWriteSnapshotAllowsAnExistingDirWithoutStaging() external { + string memory dir = "no-staging-guard"; + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + + assertEq( + this.externalWriteSnapshot(LibRainDeploySnapshot.LIB_FS_ROOT, dir), + LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable") + ); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + } +} From f04e55912097e3001bc537a2d22afb016656fd12 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 19:07:37 +0000 Subject: [PATCH 17/29] refactor(verify): name the subject, and drive the tests from the real contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Offline` named the mechanism while its sibling `Chain` named the subject, and read as a degraded mode besides. `RainDeployVerifySnapshot` pairs with `RainDeployVerifyChain` as subject against subject, and `snapshot` is already this codebase's word — `writeSnapshot`, `dirForSnapshot`, `pathForSnapshot`, `SnapshotAlreadyFrozen`. The word is gone everywhere, not just from the abstract: files, contracts, the prose that used "offline" to mean "the snapshot checks", `CLAUDE.md`, `README`. The concrete tests drop the redundant `Pins` — pins ARE the snapshot — so `AddressRegistryDeploySnapshotTest` and `AddressRegistryDeployChainTest` pair with the `AddressRegistryDeploySuites` they inherit. The inherited test functions follow: `testSnapshotInternallyConsistent`, `testSnapshotMatchesSource`, `testSuitesLiveOnEverySupportedNetwork`. The mock suite apparatus is gone with it. `MockDeploySuites`, `MockBroadcastDeploy`, `MockDuplicateSuites`, `test/generated/` and `script/BuildTestSnapshots.sol` were a second universe alongside the real one, proving the abstracts work on data no consumer has. `ExampleDeploySuites`, `ExampleDeploy` and `DuplicateDeploySuites` read `src/generated/candidate/AddressRegistry.sol` — the snapshot this repo actually generates — so a released suite and a candidate of the same contract are the configuration every consumer has rather than a simulation of it. Net 87 lines lighter, and `foundry.toml` no longer needs write access to `./test`. `MockDeployableV2` supplies one suite, and only because the chain matrix loops over suites: proving it does not stop at the first needs a suite at a DIFFERENT address, and `AddressRegistry` is the only concrete here. Without it "the matrix silently checks only the first suite" is undetectable, which is the failure mode that matters most where ten suites sit at ten addresses. Its values are derived inline, so no snapshot infrastructure comes back with it. `writeAliasLib`'s single large `string.concat` hit stack-too-deep once the file grew; split into `aliasImportBlock` and `aliasLibraryBlock`. It compiled at the previous head by margin alone. --- .github/workflows/manual-sol-artifacts.yaml | 2 +- CLAUDE.md | 33 +++-- README.md | 18 +-- script/BuildTestSnapshots.sol | 40 ------ script/Deploy.sol | 2 +- src/abstract/AddressRegistryDeploySuites.sol | 4 +- src/abstract/RainDeployBroadcast.sol | 2 +- src/abstract/RainDeployVerifyBase.sol | 10 +- src/abstract/RainDeployVerifyChain.sol | 12 +- ...fline.sol => RainDeployVerifySnapshot.sol} | 8 +- src/lib/LibAddressRegistryDeploy.sol | 6 +- src/lib/LibRainDeploySnapshot.sol | 92 +++++++++----- test/abstract/ExampleDeploySuites.sol | 72 +++++++++++ test/abstract/MockDeploySuites.sol | 71 ----------- test/concrete/DuplicateDeploySuites.sol | 53 ++++++++ ...kBroadcastDeploy.sol => ExampleDeploy.sol} | 14 +-- test/concrete/MockDuplicateSuites.sol | 56 --------- test/generated/0_0_1/MockDeployable.sol | 25 ---- test/generated/0_0_2/MockDeployableV2.sol | 25 ---- test/src/abstract/RainDeployBroadcast.t.sol | 18 +-- test/src/abstract/RainDeploySuitesBase.t.sol | 29 ++--- test/src/abstract/RainDeployVerifyChain.t.sol | 115 ++++++++++-------- ...e.t.sol => RainDeployVerifySnapshot.t.sol} | 90 +++++++------- ...t.sol => AddressRegistryDeployChain.t.sol} | 10 +- ...ol => AddressRegistryDeploySnapshot.t.sol} | 8 +- test/src/lib/GeneratedSnapshotShape.t.sol | 2 +- 26 files changed, 381 insertions(+), 436 deletions(-) delete mode 100644 script/BuildTestSnapshots.sol rename src/abstract/{RainDeployVerifyOffline.sol => RainDeployVerifySnapshot.sol} (95%) create mode 100644 test/abstract/ExampleDeploySuites.sol delete mode 100644 test/abstract/MockDeploySuites.sol create mode 100644 test/concrete/DuplicateDeploySuites.sol rename test/concrete/{MockBroadcastDeploy.sol => ExampleDeploy.sol} (68%) delete mode 100644 test/concrete/MockDuplicateSuites.sol delete mode 100644 test/generated/0_0_1/MockDeployable.sol delete mode 100644 test/generated/0_0_2/MockDeployableV2.sol rename test/src/abstract/{RainDeployVerifyOffline.t.sol => RainDeployVerifySnapshot.t.sol} (76%) rename test/src/concrete/{AddressRegistryDeployPinsChain.t.sol => AddressRegistryDeployChain.t.sol} (75%) rename test/src/concrete/{AddressRegistryDeployPinsOffline.t.sol => AddressRegistryDeploySnapshot.t.sol} (78%) diff --git a/.github/workflows/manual-sol-artifacts.yaml b/.github/workflows/manual-sol-artifacts.yaml index bd59684..5e05316 100644 --- a/.github/workflows/manual-sol-artifacts.yaml +++ b/.github/workflows/manual-sol-artifacts.yaml @@ -6,7 +6,7 @@ name: Manual sol artifacts # never broadcasts. So the deploy has to happen first, and separately, which is # this. # -# Order is: dispatch this, confirm `AddressRegistryDeployPinsChainTest` passes +# Order is: dispatch this, confirm `AddressRegistryDeployChainTest` passes # on every supported network, then push the `sol-v*` tag. # # Deliberately `workflow_dispatch` only. Broadcasting is key custody and real diff --git a/CLAUDE.md b/CLAUDE.md index 78ef193..285396a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,8 +60,8 @@ POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com All five are needed: `RainDeployVerifyChain` forks every network in `supportedNetworks()`, so a missing or rate-limited endpoint fails it. Those failures are `vm.createSelectFork` errors, distinct from the -`NotDeployedOnNetwork` a reachable network raises, and the offline contracts run -regardless: `forge test --no-match-contract Chain`. +`NotDeployedOnNetwork` a reachable network raises, and the snapshot contracts +run regardless: `forge test --no-match-contract Chain`. These are referenced in `foundry.toml` under `[rpc_endpoints]`. @@ -107,7 +107,7 @@ code hash. **`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the creation code this repo compiles under this repo's own settings and checked -against it by `AddressRegistryDeployPinsOfflineTest`. GENERATED by +against it by `AddressRegistryDeploySnapshotTest`. GENERATED by `script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. No `/` snapshot is frozen while the root is a placeholder, because that directory is append-only. @@ -149,7 +149,7 @@ text, so formatting cannot affect it: Values are deliberately not asserted: a solc change moves every literal without changing anything the shape test is about, and a wrong literal is caught -immediately by the group 1 derivation checks in `RainDeployVerifyOffline`. +immediately by the group 1 derivation checks in `RainDeployVerifySnapshot`. `ast = true` in `foundry.toml` is what puts the AST in the artifacts a plain `forge test` produces. @@ -221,14 +221,14 @@ runtime code a generated file records are checked OUTPUTS. Three groups, sorted by what they are anchored to: -1. **Internal to the recorded set** (`RainDeployVerifyOffline`) — what a version - records is what its own creation code derives. Catches a set generated - inconsistently. CANNOT catch a snapshot of the wrong contract: a consistent - snapshot of the wrong thing satisfies all of it, which +1. **Internal to the recorded set** (`RainDeployVerifySnapshot`) — what a + version records is what its own creation code derives. Catches a set + generated inconsistently. CANNOT catch a snapshot of the wrong contract: a + consistent snapshot of the wrong thing satisfies all of it, which `testWrongContractSnapshotPassesInternalConsistency` pins. -2. **Anchored to source** (`RainDeployVerifyOffline`) — the candidate's recorded - creation code is `type(X).creationCode`. The only check that catches a - wrong-contract snapshot. Candidate only, because a released tag is MEANT to +2. **Anchored to source** (`RainDeployVerifySnapshot`) — the candidate's + recorded creation code is `type(X).creationCode`. The only check that catches + a wrong-contract snapshot. Candidate only, because a released tag is MEANT to diverge from current source; there is no field on a released version to spell it, so it cannot be opted into or out of. 3. **Anchored to chain** (`RainDeployVerifyChain`) — across @@ -238,9 +238,8 @@ Three groups, sorted by what they are anchored to: touching it. Group 3 lives in its own contract so an unreachable RPC endpoint fails only it, -never the assertions that hold offline — `forge test --no-match-contract Chain` -is the whole offline gate, and nothing reachable from those contracts forks -anything. +never the snapshot assertions — `forge test --no-match-contract Chain` is the +whole snapshot gate, and nothing reachable from those contracts forks anything. A single recorded code hash per version can only be true if the runtime code is the same on every network, so a constructor reading `block.chainid` or similar @@ -285,9 +284,9 @@ expected addresses, expected code hashes, and dependency lists. things. `script/Deploy.sol` broadcasts `AddressRegistry` to every network in `supportedNetworks()`, dispatched by hand through `.github/workflows/manual-sol-artifacts.yaml`. Only then can - `AddressRegistryDeployPinsChainTest` pass, and only then is there a deployment - for `rainix-tag-release` to verify pins against — it verifies and publishes, - it never broadcasts. Broadcasting is key custody and real money, so it is + `AddressRegistryDeployChainTest` pass, and only then is there a deployment for + `rainix-tag-release` to verify pins against — it verifies and publishes, it + never broadcasts. Broadcasting is key custody and real money, so it is `workflow_dispatch` and nothing else. Deploying is idempotent: a network that already has the code is skipped, so a partial run is fixed by running it again. diff --git a/README.md b/README.md index 4173ab7..84b24b4 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,11 @@ abstract contract MyDeploySuites is RainDeploySuitesBase { // script/Deploy.sol contract Deploy is MyDeploySuites, RainDeployBroadcast {} -// test/src/concrete/MyDeployPinsOffline.t.sol -contract MyDeployPinsOfflineTest is MyDeploySuites, RainDeployVerifyOffline {} +// test/src/concrete/MyDeploySnapshot.t.sol +contract MyDeploySnapshotTest is MyDeploySuites, RainDeployVerifySnapshot {} -// test/src/concrete/MyDeployPinsChain.t.sol -contract MyDeployPinsChainTest is MyDeploySuites, RainDeployVerifyChain {} +// test/src/concrete/MyDeployChain.t.sol +contract MyDeployChainTest is MyDeploySuites, RainDeployVerifyChain {} ``` The broadcast and the verification read the SAME array. "The deploy script ships @@ -112,8 +112,8 @@ one that has never been deployed — where it fails, and that failure is the answer. It is a separate contract so that an unreachable RPC endpoint fails only it. -`forge test --no-match-contract Chain` is the whole offline gate, and it is -structural rather than conventional: nothing reachable from the offline +`forge test --no-match-contract Chain` is the whole snapshot gate, and it is +structural rather than conventional: nothing reachable from the snapshot contracts forks anything. **Chain-independent runtime code is a requirement, not a caveat.** One recorded @@ -171,9 +171,9 @@ Three separate steps, in this order. Nothing automatic ever broadcasts. custody and real money, and no merge or tag should be able to trigger it. It is idempotent — a network that already has the code is skipped — so a partial run is fixed by running it again rather than by unpicking anything. -2. **Verify.** `AddressRegistryDeployPinsChainTest` passes only once every - supported network has the registry, with the code this repo compiles. It is - red today because step 1 has never been run. +2. **Verify.** `AddressRegistryDeployChainTest` passes only once every supported + network has the registry, with the code this repo compiles. It is red today + because step 1 has never been run. 3. **Tag.** Push a `sol-v*` tag. `rainix-tag-release` regenerates the snapshot for the version the tag names, verifies the live chains against those fresh pins, publishes to Soldeer and commits the frozen snapshot back to `main`. It diff --git a/script/BuildTestSnapshots.sol b/script/BuildTestSnapshots.sol deleted file mode 100644 index 5fdb662..0000000 --- a/script/BuildTestSnapshots.sol +++ /dev/null @@ -1,40 +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 {Script} from "forge-std-1.16.1/src/Script.sol"; -import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; -import {MockDeployable} from "../test/concrete/MockDeployable.sol"; -import {MockDeployableV2} from "../test/concrete/MockDeployableV2.sol"; - -/// @title BuildTestSnapshots -/// @notice Generates the deploy snapshots the verification abstracts are -/// exercised against, into `test/generated/`. -/// -/// Same generator as `script/Build.sol` — both call -/// `LibRainDeploySnapshot.writeSnapshot`, which is parameterised on the -/// declaration and the output root. This is not a helper, not a reference and -/// not hand-maintained: it is the production code path pointed at test -/// contracts, so `GeneratedSnapshotShapeTest`'s assertions describe the same -/// emitter that writes real deploy records. -/// -/// `test/` is excluded by `.soldeerignore`, so these never ship in the package — -/// which is the whole reason they are not under `src/generated/`. -/// -/// Run as `forge script script/BuildTestSnapshots.sol`. -contract BuildTestSnapshots is Script { - /// @notice Where test snapshots live. - string constant TEST_GENERATED_ROOT = "test/generated"; - - /// @notice Regenerate every test snapshot. Two contracts, so the abstracts - /// see two suites at two different addresses — which is what a repo with a - /// version history actually looks like. - function run() external { - LibRainDeploySnapshot.writeSnapshot( - vm, TEST_GENERATED_ROOT, "0_0_1", "MockDeployable", type(MockDeployable).creationCode - ); - LibRainDeploySnapshot.writeSnapshot( - vm, TEST_GENERATED_ROOT, "0_0_2", "MockDeployableV2", type(MockDeployableV2).creationCode - ); - } -} diff --git a/script/Deploy.sol b/script/Deploy.sol index 7a500cb..dfceb7f 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -27,7 +27,7 @@ import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploy /// chains of five, one RPC down — is fixed by running it again rather than by /// unpicking anything. /// -/// `AddressRegistryDeployPinsChainTest` is what says whether this has been run +/// `AddressRegistryDeployChainTest` is what says whether this has been run /// and worked. It fails until every supported network has the registry, which /// is the state this repo is in right now. contract Deploy is AddressRegistryDeploySuites, RainDeployBroadcast {} diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol index b3cedb0..b953eb6 100644 --- a/src/abstract/AddressRegistryDeploySuites.sol +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -16,9 +16,9 @@ import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; /// Three contracts inherit this and nothing else declares a suite: /// /// - `script/Deploy.sol` broadcasts from it -/// - `AddressRegistryDeployPinsOfflineTest` checks its records against its +/// - `AddressRegistryDeploySnapshotTest` checks its records against its /// creation code -/// - `AddressRegistryDeployPinsChainTest` checks it against every chain +/// - `AddressRegistryDeployChainTest` checks it against every chain /// /// So "the deploy script broadcasts one contract while the tests verify /// another" is not a thing that can be true here. Not because something checks diff --git a/src/abstract/RainDeployBroadcast.sol b/src/abstract/RainDeployBroadcast.sol index 0a2ed25..723d74b 100644 --- a/src/abstract/RainDeployBroadcast.sol +++ b/src/abstract/RainDeployBroadcast.sol @@ -35,7 +35,7 @@ import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; /// /// The recorded address and code hash are passed to `deployAndBroadcast`, not /// derived from the creation code here. They are derivable — that is exactly -/// what `RainDeployVerifyOffline` derives them for — but deriving them at +/// what `RainDeployVerifySnapshot` derives them for — but deriving them at /// broadcast time would defeat the check that matters most at broadcast time. /// `LibRainDeploy.deployToNetworks` compares the recorded address against the /// address the creation code derives BEFORE it forks anything, precisely so a diff --git a/src/abstract/RainDeployVerifyBase.sol b/src/abstract/RainDeployVerifyBase.sol index 80f4c66..dc62f0d 100644 --- a/src/abstract/RainDeployVerifyBase.sol +++ b/src/abstract/RainDeployVerifyBase.sol @@ -26,7 +26,7 @@ error ZoltuDerivationMismatch(string suite, address formulaAddress, address fact /// @param snapshotId The snapshot that could not be reverted. error DerivationSnapshotRevertFailed(string suite, uint256 snapshotId); -/// What a suite's creation code derives, offline and by itself. Computed +/// What a suite's creation code derives, by itself. Computed /// once and then compared against whatever claims to hold it, whether that is a /// recorded constant or a live chain. struct DerivedDeploy { @@ -47,13 +47,13 @@ struct DerivedDeploy { /// declaration `RainDeployBroadcast` deploys from. Verification and deployment /// therefore cannot describe different things. /// -/// This is not inherited directly. `RainDeployVerifyOffline` and +/// This is not inherited directly. `RainDeployVerifySnapshot` and /// `RainDeployVerifyChain` each inherit it and contribute the checks that need /// no network and the checks that do, respectively. A repo inherits its -/// declaration into one of each, so running the offline checks never touches an -/// RPC endpoint — an outage is then a failure of one contract that plainly is +/// declaration into one of each, so running the snapshot checks never touches +/// an RPC endpoint — an outage is then a failure of one contract that plainly is /// about the chain, and can never be confused with, or take down, the -/// assertions that hold offline. +/// snapshot assertions. /// /// ## Chain-independent runtime code is a requirement, not a caveat /// diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol index e52f7ec..f87833f 100644 --- a/src/abstract/RainDeployVerifyChain.sol +++ b/src/abstract/RainDeployVerifyChain.sol @@ -46,17 +46,17 @@ error CodeHashMismatchOnNetwork( /// or per-suite functions to add. /// /// It compares against the DERIVED code hash rather than the recorded one, so -/// the creation code stays the only parameter. `RainDeployVerifyOffline` is +/// the creation code stays the only parameter. `RainDeployVerifySnapshot` is /// what ties the derivation back to the recorded constants; the two together /// say the recorded set describes what is actually live. /// -/// Kept in its own contract, away from every assertion that holds offline, so -/// an unreachable RPC endpoint fails only this. It cannot take down the offline -/// checks with it, and its failures are legible: a fork that cannot be created +/// Kept in its own contract, away from every assertion about the snapshot, so +/// an unreachable RPC endpoint fails only this. It cannot take down the +/// snapshot checks with it, and its failures are legible: a fork that cannot be created /// is an outage, while `NotDeployedOnNetwork` from a fork that was created is a /// missing deployment. A contract boundary is what `forge test /// --match-contract` and a CI job select at, and it is structural rather than -/// conventional — nothing reachable from the offline contract forks anything. +/// conventional — nothing reachable from the snapshot contract forks anything. abstract contract RainDeployVerifyChain is RainDeployVerifyBase { /// Checks one derived suite against whichever network is currently /// selected. @@ -98,7 +98,7 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { /// Every declared suite MUST be live, with the code its creation code /// produces, on every supported network. - function testDeployPinsLiveOnEverySupportedNetwork() external { + function testSuitesLiveOnEverySupportedNetwork() external { checkDeployedOnSupportedNetworks(deriveDeployments(allSuites())); } } diff --git a/src/abstract/RainDeployVerifyOffline.sol b/src/abstract/RainDeployVerifySnapshot.sol similarity index 95% rename from src/abstract/RainDeployVerifyOffline.sol rename to src/abstract/RainDeployVerifySnapshot.sol index 61ba4ae..e1a5d8b 100644 --- a/src/abstract/RainDeployVerifyOffline.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -36,7 +36,7 @@ error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, by /// contract the candidate claims to be. error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); -/// @title RainDeployVerifyOffline +/// @title RainDeployVerifySnapshot /// @notice Every deploy-pin assertion that needs no network, for every suite /// a repo declares. Two groups, which catch different things and are documented /// as such because it is easy to read the first as covering the second. @@ -63,7 +63,7 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// Neither group can catch a suite that was never deployed, or that is no /// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a /// substitute for it. -abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { +abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { /// Checks one suite against itself: derive from its creation code, then /// require everything it records to agree with the derivation. /// @param suite The suite to check. @@ -98,7 +98,7 @@ abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { /// Every declared suite MUST be internally consistent: what it records is /// what its own creation code derives. - function testDeployPinsInternallyConsistent() external { + function testSnapshotInternallyConsistent() external { DeploySuite[] memory suites = allSuites(); for (uint256 i = 0; i < suites.length; i++) { checkInternallyConsistent(suites[i]); @@ -107,7 +107,7 @@ abstract contract RainDeployVerifyOffline is RainDeployVerifyBase { /// The candidate MUST be a snapshot of the contract this repo compiles, not /// of some other contract that happens to be internally consistent. - function testDeployPinsCandidateAnchoredToSource() external pure { + function testSnapshotMatchesSource() external pure { checkAnchoredToSource(candidateSuite()); } } diff --git a/src/lib/LibAddressRegistryDeploy.sol b/src/lib/LibAddressRegistryDeploy.sol index 233498b..c85e214 100644 --- a/src/lib/LibAddressRegistryDeploy.sol +++ b/src/lib/LibAddressRegistryDeploy.sol @@ -11,9 +11,9 @@ import { /// @title LibAddressRegistryDeploy /// @notice The deterministic Zoltu deploy address and code hash of -/// `AddressRegistry` as this repo currently compiles it, aliased from the -/// rolling `src/generated/candidate/AddressRegistry.sol` snapshot so -/// that snapshot stays the single source of truth. +/// `AddressRegistry`, aliased from its generated snapshot so that snapshot stays the +/// single source of truth. The import path never moves, so consumers are +/// unaffected by which snapshot it names. library LibAddressRegistryDeploy { address constant ADDRESS_REGISTRY_DEPLOYED_ADDRESS = ADDRESS_REGISTRY_ADDR; bytes32 constant ADDRESS_REGISTRY_DEPLOYED_CODEHASH = ADDRESS_REGISTRY_HASH; diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 0123f1d..b0f8164 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -247,6 +247,60 @@ library LibRainDeploySnapshot { return dest; } + /// The import block of a generated alias lib. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants. + /// @param dir The snapshot directory to alias. + /// @return The import block. + function aliasImportBlock(string memory contractName, string memory constantPrefix, string memory dir) + internal + pure + returns (string memory) + { + return string.concat( + "import {\n DEPLOYED_ADDRESS as ", + constantPrefix, + "_ADDR,\n BYTECODE_HASH as ", + constantPrefix, + "_HASH\n} from \"../generated/", + dir, + "/", + contractName, + ".sol\";\n\n" + ); + } + + /// The library block of a generated alias lib. + /// @param contractName The contract the snapshot describes. + /// @param constantPrefix The prefix for the emitted constants. + /// @param libraryName The generated library's name. + /// @return The library block. + function aliasLibraryBlock(string memory contractName, string memory constantPrefix, string memory libraryName) + internal + pure + returns (string memory) + { + return string.concat( + "/// @title ", + libraryName, + "\n/// @notice The deterministic Zoltu deploy address and code hash of\n/// `", + contractName, + "`, aliased from its generated snapshot so that snapshot stays the\n", + "/// single source of truth. The import path never moves, so consumers are\n", + "/// unaffected by which snapshot it names.\nlibrary ", + libraryName, + " {\n address constant ", + constantPrefix, + "_DEPLOYED_ADDRESS = ", + constantPrefix, + "_ADDR;\n bytes32 constant ", + constantPrefix, + "_DEPLOYED_CODEHASH = ", + constantPrefix, + "_HASH;\n}\n" + ); + } + /// Generate the alias lib for a snapshot: the stable, consumer-facing /// import path that re-exports one snapshot's address and code hash. /// @@ -289,7 +343,6 @@ library LibRainDeploySnapshot { { string memory libraryName = string.concat("Lib", contractName, "Deploy"); string memory path = string.concat("src/lib/", libraryName, ".sol"); - string memory importPath = string.concat("../generated/", dir, "/", contractName, ".sol"); // REUSE-IgnoreStart (the SPDX lines below are the header EMITTED into // the generated lib, not this file's own license) @@ -297,39 +350,12 @@ library LibRainDeploySnapshot { vm.writeFile( path, string.concat( - "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n", - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", - "pragma solidity ^0.8.25;\n\n", + "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n" + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n" + "pragma solidity ^0.8.25;\n\n" "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", - "import {\n", - " DEPLOYED_ADDRESS as ", - constantPrefix, - "_ADDR,\n BYTECODE_HASH as ", - constantPrefix, - "_HASH\n} from \"", - importPath, - "\";\n\n", - "/// @title ", - libraryName, - "\n/// @notice The deterministic Zoltu deploy address and code hash of\n/// `", - contractName, - "`, aliased from the `src/generated/", - dir, - "/", - contractName, - ".sol`\n/// snapshot so that snapshot stays the single source of truth. The import\n", - "/// path never moves, so consumers are unaffected by which snapshot it names.\n", - "library ", - libraryName, - " {\n address constant ", - constantPrefix, - "_DEPLOYED_ADDRESS = ", - constantPrefix, - "_ADDR;\n bytes32 constant ", - constantPrefix, - "_DEPLOYED_CODEHASH = ", - constantPrefix, - "_HASH;\n}\n" + aliasImportBlock(contractName, constantPrefix, dir), + aliasLibraryBlock(contractName, constantPrefix, libraryName) ) ); // REUSE-IgnoreEnd diff --git a/test/abstract/ExampleDeploySuites.sol b/test/abstract/ExampleDeploySuites.sol new file mode 100644 index 0000000..759e50b --- /dev/null +++ b/test/abstract/ExampleDeploySuites.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; +import {LibRainDeploy} from "../../src/lib/LibRainDeploy.sol"; +import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; + +/// @title ExampleDeploySuites +/// @notice A deploy repo's suite declaration, as the verification abstracts see +/// it: a released `AddressRegistry` and a candidate `AddressRegistry`, both +/// reading the real `src/generated/candidate/AddressRegistry.sol` snapshot this +/// repo generates. +/// +/// That pair is the configuration every consumer has, and it is what makes the +/// released and candidate paths, and two suites deriving one address, real +/// rather than simulated. +/// +/// The third suite exists for one reason: the chain matrix loops over suites, +/// and proving it does not stop at the first requires a suite at a DIFFERENT +/// address. `AddressRegistry` is the only concrete in this repo, so the second +/// address comes from `MockDeployableV2`, which is already on main for +/// `LibRainDeploy`'s own tests. Without it, "the matrix silently checks only +/// the first suite" is undetectable — the failure mode that matters most to the +/// repos this abstract exists for, where ten suites sit at ten addresses. +abstract contract ExampleDeploySuites is RainDeploySuitesBase { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](2); + suites[0] = DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + suites[1] = DeploySuite({ + suite: "second-address", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "address-registry-candidate", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); + } +} diff --git a/test/abstract/MockDeploySuites.sol b/test/abstract/MockDeploySuites.sol deleted file mode 100644 index 683419d..0000000 --- a/test/abstract/MockDeploySuites.sol +++ /dev/null @@ -1,71 +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 {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; -import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; -import { - BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, - CREATION_CODE as MOCK_CREATION_CODE_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 -} from "../generated/0_0_1/MockDeployable.sol"; -import { - BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_2, - CREATION_CODE as MOCK_CREATION_CODE_0_0_2, - DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_2, - RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_2 -} from "../generated/0_0_2/MockDeployableV2.sol"; - -/// @title MockDeploySuites -/// @notice A deploy repo's suite declaration, for exercising the verification -/// abstracts: two frozen releases plus a candidate, over two different -/// contracts at two different addresses — which is what a repo with a version -/// history actually looks like. `0_0_2` and the candidate are the same bytes -/// under different keys, so two suites derive one address. -/// -/// Both snapshots are REAL generator output, emitted by -/// `script/BuildTestSnapshots.sol` through the same -/// `LibRainDeploySnapshot.writeSnapshot` that writes production deploy records. -/// There is no hand-maintained hex in this repo: a solc bump is -/// `forge script script/BuildTestSnapshots.sol` and commit. -abstract contract MockDeploySuites is RainDeploySuitesBase { - /// @inheritdoc RainDeploySuitesBase - function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { - suites = new DeploySuite[](2); - suites[0] = DeploySuite({ - suite: "mock-deployable-0-0-1", - creationCode: MOCK_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, - artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", - dependencies: new address[](0) - }); - suites[1] = DeploySuite({ - suite: "mock-deployable-v2-0-0-2", - creationCode: MOCK_CREATION_CODE_0_0_2, - storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_2, - storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_2, - storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_2, - artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", - dependencies: new address[](0) - }); - } - - /// @inheritdoc RainDeploySuitesBase - function candidateSuite() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ - snapshot: DeploySuite({ - suite: "mock-deployable-v2-candidate", - creationCode: MOCK_CREATION_CODE_0_0_2, - storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_2, - storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_2, - storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_2, - artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", - dependencies: new address[](0) - }), - sourceCreationCode: type(MockDeployableV2).creationCode - }); - } -} diff --git a/test/concrete/DuplicateDeploySuites.sol b/test/concrete/DuplicateDeploySuites.sol new file mode 100644 index 0000000..a4185a0 --- /dev/null +++ b/test/concrete/DuplicateDeploySuites.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 {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../src/generated/candidate/AddressRegistry.sol"; + +/// @title DuplicateDeploySuites +/// A declaration whose released suite and candidate share a key — the one thing +/// a registry must refuse, because the key is what selects what gets broadcast. +contract DuplicateDeploySuites is RainDeploySuitesBase { + /// The suite both entries declare, identically. + /// @return The colliding suite. + function collidingSuite() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "collides", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = collidingSuite(); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({snapshot: collidingSuite(), sourceCreationCode: type(AddressRegistry).creationCode}); + } + + /// @return Every declared suite. + function externalAllSuites() external pure returns (DeploySuite[] memory) { + return allSuites(); + } + + /// @param requested The suite key to select. + /// @return The selected suite. + function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { + return suiteByName(requested); + } +} diff --git a/test/concrete/MockBroadcastDeploy.sol b/test/concrete/ExampleDeploy.sol similarity index 68% rename from test/concrete/MockBroadcastDeploy.sol rename to test/concrete/ExampleDeploy.sol index d9b129c..0d2fc0b 100644 --- a/test/concrete/MockBroadcastDeploy.sol +++ b/test/concrete/ExampleDeploy.sol @@ -4,14 +4,14 @@ pragma solidity =0.8.25; import {DeploySuite} from "../../src/abstract/RainDeploySuitesBase.sol"; import {RainDeployBroadcast} from "../../src/abstract/RainDeployBroadcast.sol"; -import {MockDeploySuites} from "../abstract/MockDeploySuites.sol"; +import {ExampleDeploySuites} from "../abstract/ExampleDeploySuites.sol"; -/// @title MockBroadcastDeploy -/// A deploy repo's whole script, as a fixture — the fixture suite declaration -/// plus `RainDeployBroadcast` and nothing else, which is exactly what -/// `script/Deploy.sol` is. The external wrappers exist so a plain `Test` -/// contract can drive the internals without inheriting `Script`. -contract MockBroadcastDeploy is MockDeploySuites, RainDeployBroadcast { +/// @title ExampleDeploy +/// A deploy repo's whole script — a suite declaration plus `RainDeployBroadcast` +/// and nothing else, which is exactly what `script/Deploy.sol` is. The external +/// wrappers let a plain `Test` contract drive the internals without inheriting +/// `Script`. +contract ExampleDeploy is ExampleDeploySuites, RainDeployBroadcast { /// @param requested The suite key to select. /// @return The selected suite. function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { diff --git a/test/concrete/MockDuplicateSuites.sol b/test/concrete/MockDuplicateSuites.sol deleted file mode 100644 index 62c88e6..0000000 --- a/test/concrete/MockDuplicateSuites.sol +++ /dev/null @@ -1,56 +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 {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; -import {MockDeployable} from "./MockDeployable.sol"; -import {MockDeployableV2} from "./MockDeployableV2.sol"; - -/// @title MockDuplicateSuites -/// A declaration whose released suite and candidate share a key, which is the -/// one thing a registry must refuse: the key is what selects what gets -/// broadcast, so a duplicate makes the selection ambiguous and leaves one of -/// the two unreachable. Deliberately different CONTRACTS under the one key, so -/// the ambiguity is a real one. -contract MockDuplicateSuites is RainDeploySuitesBase { - /// @inheritdoc RainDeploySuitesBase - function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { - suites = new DeploySuite[](1); - suites[0] = DeploySuite({ - suite: "collides", - creationCode: type(MockDeployable).creationCode, - storedDeployedAddress: address(0), - storedBytecodeHash: bytes32(0), - storedRuntimeCode: hex"", - artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", - dependencies: new address[](0) - }); - } - - /// @inheritdoc RainDeploySuitesBase - function candidateSuite() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ - snapshot: DeploySuite({ - suite: "collides", - creationCode: type(MockDeployableV2).creationCode, - storedDeployedAddress: address(0), - storedBytecodeHash: bytes32(0), - storedRuntimeCode: hex"", - artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", - dependencies: new address[](0) - }), - sourceCreationCode: type(MockDeployableV2).creationCode - }); - } - - /// @return Every declared suite. - function externalAllSuites() external pure returns (DeploySuite[] memory) { - return allSuites(); - } - - /// @param requested The suite key to select. - /// @return The selected suite. - function externalSuiteByName(string memory requested) external pure returns (DeploySuite memory) { - return suiteByName(requested); - } -} diff --git a/test/generated/0_0_1/MockDeployable.sol b/test/generated/0_0_1/MockDeployable.sol deleted file mode 100644 index 74b25a5..0000000 --- a/test/generated/0_0_1/MockDeployable.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. - -// It is committed to the repository because there is a circular dependency -// between the contract and its generated file. The contract needs the -// generated file to exist so that it can compile, and the generated file -// needs the contract to exist so that it can be compiled. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0x0cff4019cbc9f3009ec77b6438233bbe4c5d991a5766aa56c97dbb593feb3663); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0x0c04367b381F8Ca252aD2516F1Eac2b9B2ca928F); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602a5f553480156012575f80fd5b50604380601e5f395ff3fe6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106026575f3560e01c80633fa4f24514602a575b5f80fd5b60315f5481565b60405190815260200160405180910390f3"; diff --git a/test/generated/0_0_2/MockDeployableV2.sol b/test/generated/0_0_2/MockDeployableV2.sol deleted file mode 100644 index af85817..0000000 --- a/test/generated/0_0_2/MockDeployableV2.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity ^0.8.25; - -// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. - -// It is committed to the repository because there is a circular dependency -// between the contract and its generated file. The contract needs the -// generated file to exist so that it can compile, and the generated file -// needs the contract to exist so that it can be compiled. - -/// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0xf80fdab74d5f11f3901f56541fc0b1242013dbca435f771819dbc09022d1d604); - -/// @dev The deterministic deploy address of the contract when deployed via -/// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0xE19c2335AdbFAD3250FA150739cC5C11cE5935eD); - -/// @dev The creation bytecode of the contract. -bytes constant CREATION_CODE = - hex"6080604052602b5f5560636001553480156017575f80fd5b5060558060235f395ff3fe6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; - -/// @dev The runtime bytecode of the contract. -bytes constant RUNTIME_CODE = - hex"6080604052348015600e575f80fd5b50600436106030575f3560e01c80633fa4f2451460345780638529587714604d575b5f80fd5b603b5f5481565b60405190815260200160405180910390f35b603b6001548156"; diff --git a/test/src/abstract/RainDeployBroadcast.t.sol b/test/src/abstract/RainDeployBroadcast.t.sol index d2ceb0c..528b456 100644 --- a/test/src/abstract/RainDeployBroadcast.t.sol +++ b/test/src/abstract/RainDeployBroadcast.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {UnknownDeploymentSuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {MockBroadcastDeploy} from "../../concrete/MockBroadcastDeploy.sol"; +import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; /// @title RainDeployBroadcastTest /// @notice The broadcast entry point, driven exactly as the `Manual sol @@ -18,12 +18,12 @@ import {MockBroadcastDeploy} from "../../concrete/MockBroadcastDeploy.sol"; /// tested without a key, an RPC and real money — and, not coincidentally, the /// half that decides WHAT would be deployed. contract RainDeployBroadcastTest is Test { - MockBroadcastDeploy internal sDeploy; + ExampleDeploy internal sDeploy; /// A deploy repo's whole script: the fixture declaration plus /// `RainDeployBroadcast`. function setUp() external { - sDeploy = new MockBroadcastDeploy(); + sDeploy = new ExampleDeploy(); } /// A mistyped suite MUST fail naming every valid suite, and MUST do so @@ -37,7 +37,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "address-registry", - "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate" ) ); sDeploy.run(); @@ -53,7 +53,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate" ) ); sDeploy.run(); @@ -83,12 +83,12 @@ contract RainDeployBroadcastTest is Test { /// make that comparison derived-against-derived. function testSelectedSuiteCarriesTheRecordedPins() external view { assertEq( - sDeploy.externalSuiteByName("mock-deployable-0-0-1").storedDeployedAddress, - LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("mock-deployable-0-0-1").creationCode) + sDeploy.externalSuiteByName("address-registry-0-0-1").storedDeployedAddress, + LibRainDeploy.zoltuAddress(sDeploy.externalSuiteByName("address-registry-0-0-1").creationCode) ); assertEq( - sDeploy.externalSuiteByName("mock-deployable-0-0-1").artifactPath, - "test/concrete/MockDeployable.sol:MockDeployable" + sDeploy.externalSuiteByName("address-registry-0-0-1").artifactPath, + "src/concrete/AddressRegistry.sol:AddressRegistry" ); } } diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index cdea11b..21cae8a 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -9,8 +9,8 @@ import { DuplicateDeploySuite, UnknownDeploymentSuite } from "../../../src/abstract/RainDeploySuitesBase.sol"; -import {MockBroadcastDeploy} from "../../concrete/MockBroadcastDeploy.sol"; -import {MockDuplicateSuites} from "../../concrete/MockDuplicateSuites.sol"; +import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; +import {DuplicateDeploySuites} from "../../concrete/DuplicateDeploySuites.sol"; /// @title RainDeploySuitesBaseTest /// @notice The registry itself: one declaration, keyed lookup, and the two ways @@ -22,11 +22,11 @@ import {MockDuplicateSuites} from "../../concrete/MockDuplicateSuites.sol"; /// so the failure message and the actual set of suites are free to drift. Here /// they are the same array, which is what these tests pin. contract RainDeploySuitesBaseTest is Test { - MockBroadcastDeploy internal sSuites; + ExampleDeploy internal sSuites; /// The fixture declaration, as a deploy script would inherit it. function setUp() external { - sSuites = new MockBroadcastDeploy(); + sSuites = new ExampleDeploy(); } /// The registry MUST be the released suites followed by the candidate, in @@ -37,9 +37,9 @@ contract RainDeploySuitesBaseTest is Test { DeploySuite[] memory suites = sSuites.externalAllSuites(); assertEq(suites.length, 3); - assertEq(suites[0].suite, "mock-deployable-0-0-1"); - assertEq(suites[1].suite, "mock-deployable-v2-0-0-2"); - assertEq(suites[2].suite, "mock-deployable-v2-candidate"); + assertEq(suites[0].suite, "address-registry-0-0-1"); + assertEq(suites[1].suite, "second-address"); + assertEq(suites[2].suite, "address-registry-candidate"); } /// Every declared key MUST select its own suite. A deploy is dispatched per @@ -62,8 +62,8 @@ contract RainDeploySuitesBaseTest is Test { /// so the key is the only thing that distinguishes them — and it has to, /// because they are separately deployable records. function testSuitesSharingCreationCodeSelectApart() external view { - DeploySuite memory released = sSuites.externalSuiteByName("mock-deployable-v2-0-0-2"); - DeploySuite memory candidate = sSuites.externalSuiteByName("mock-deployable-v2-candidate"); + DeploySuite memory released = sSuites.externalSuiteByName("address-registry-0-0-1"); + DeploySuite memory candidate = sSuites.externalSuiteByName("address-registry-candidate"); assertEq(keccak256(released.creationCode), keccak256(candidate.creationCode)); assertEq(released.storedDeployedAddress, candidate.storedDeployedAddress); @@ -78,7 +78,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "mock-deployable", - "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate" ) ); sSuites.externalSuiteByName("mock-deployable"); @@ -91,7 +91,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate" ) ); sSuites.externalSuiteByName(""); @@ -99,10 +99,7 @@ contract RainDeploySuitesBaseTest is Test { /// The reported key list MUST be exactly the registry, in order. function testSuiteNamesIsTheRegistry() external view { - assertEq( - sSuites.externalSuiteNames(), - "mock-deployable-0-0-1, mock-deployable-v2-0-0-2, mock-deployable-v2-candidate" - ); + assertEq(sSuites.externalSuiteNames(), "address-registry-0-0-1, second-address, address-registry-candidate"); } /// Two suites under one key MUST fail, on BOTH paths that read the @@ -110,7 +107,7 @@ contract RainDeploySuitesBaseTest is Test { /// unreachable, and it is checked where both the deploy side and the verify /// side pay for it rather than in either one of them. function testDuplicateSuiteKeyReverts() external { - MockDuplicateSuites duplicates = new MockDuplicateSuites(); + DuplicateDeploySuites duplicates = new DuplicateDeploySuites(); vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); duplicates.externalAllSuites(); diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 7f862ea..e92efb9 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -9,23 +9,19 @@ import { RainDeployVerifyChain } from "../../../src/abstract/RainDeployVerifyChain.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; +import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { - BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, - CREATION_CODE as MOCK_CREATION_CODE_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 -} from "../../generated/0_0_1/MockDeployable.sol"; -import { - BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_2, - DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_2, - RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_2 -} from "../../generated/0_0_2/MockDeployableV2.sol"; + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; /// @title RainDeployVerifyChainTest /// @notice `RainDeployVerifyChain` inherited by a exemplar repo whose versions /// are made live on every network by `setUp`, so the inherited -/// `testDeployPinsLiveOnEverySupportedNetwork` is the passing case: it forks +/// `testSuitesLiveOnEverySupportedNetwork` is the passing case: it forks /// every network `supportedNetworks()` returns and finds all three suites. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the @@ -40,15 +36,28 @@ import { /// the assertion. `testChainCodeHashMismatchReverts` is what proves it: it /// leaves the etch in place and changes only the code, and the check still /// fails, which it could not do if the expectation were read from the etch. -contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { +contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain { + /// The second suite's address, derived from the only other creation code in + /// this repo. + /// @return The address. + function secondDeployedAddress() internal pure returns (address) { + return LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); + } + + /// The second suite's runtime code. + /// @return The runtime code. + function secondRuntimeCode() internal pure returns (bytes memory) { + return type(MockDeployableV2).runtimeCode; + } + /// Makes every exemplar version live on every fork, which is what the /// inherited test then verifies. Persistent so it survives each /// `createSelectFork` inside the loop. function setUp() external { - vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, MOCK_RUNTIME_CODE_0_0_1); - vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); - vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_2, MOCK_RUNTIME_CODE_0_0_2); - vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_2); + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + vm.etch(secondDeployedAddress(), secondRuntimeCode()); + vm.makePersistent(secondDeployedAddress()); } /// External wrapper for `checkDeployedOnNetwork` so `vm.expectRevert` works @@ -65,34 +74,31 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// release that therefore never got it, is invisible to every other check. function testChainNotDeployedReverts() external { // Present locally, but no longer carried onto forks. - vm.revokePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); + vm.revokePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); vm.expectRevert( abi.encodeWithSelector( NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "mock-deployable-0-0-1", - MOCK_DEPLOYED_ADDRESS_0_0_1 + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); - this.testDeployPinsLiveOnEverySupportedNetwork(); + this.testSuitesLiveOnEverySupportedNetwork(); } /// EVERY suite MUST be checked, not just the first one the matrix /// reaches. The version missing here is the second and third, so a matrix /// that stopped after the first version would pass. function testChainNotDeployedRevertsForALaterSuite() external { - vm.revokePersistent(MOCK_DEPLOYED_ADDRESS_0_0_2); + vm.revokePersistent(secondDeployedAddress()); vm.expectRevert( abi.encodeWithSelector( - NotDeployedOnNetwork.selector, - LibRainDeploy.ARBITRUM_ONE, - "mock-deployable-v2-0-0-2", - MOCK_DEPLOYED_ADDRESS_0_0_2 + NotDeployedOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, "second-address", secondDeployedAddress() ) ); - this.testDeployPinsLiveOnEverySupportedNetwork(); + this.testSuitesLiveOnEverySupportedNetwork(); } /// EVERY network MUST be forked, not just the first. A completed matrix @@ -112,7 +118,7 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { (firstForkId); assertNotEq(block.chainid, lastChainId); - this.testDeployPinsLiveOnEverySupportedNetwork(); + this.testSuitesLiveOnEverySupportedNetwork(); assertEq(block.chainid, lastChainId); } @@ -129,19 +135,19 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// observed: the wrong code is etched at the address the check reads, so if /// the derivation took its expectation from there this would pass. function testChainCodeHashMismatchReverts() external { - vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, hex"6001"); + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, hex"6001"); vm.expectRevert( abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.ARBITRUM_ONE, - "mock-deployable-0-0-1", - MOCK_DEPLOYED_ADDRESS_0_0_1, - MOCK_BYTECODE_HASH_0_0_1, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + ADDRESS_REGISTRY_BYTECODE_HASH, keccak256(hex"6001") ) ); - this.testDeployPinsLiveOnEverySupportedNetwork(); + this.testSuitesLiveOnEverySupportedNetwork(); } /// The network in the failure MUST be the network that failed, not a fixed @@ -152,8 +158,8 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { vm.createSelectFork(LibRainDeploy.BASE); DerivedDeploy memory derived = DerivedDeploy({ - suite: "mock-deployable-0-0-1", - deployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, + suite: "address-registry-0-0-1", + deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytecodeHash: bytes32(uint256(1)) }); @@ -161,10 +167,10 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { abi.encodeWithSelector( CodeHashMismatchOnNetwork.selector, LibRainDeploy.BASE, - "mock-deployable-0-0-1", - MOCK_DEPLOYED_ADDRESS_0_0_1, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, bytes32(uint256(1)), - MOCK_BYTECODE_HASH_0_0_1 + ADDRESS_REGISTRY_BYTECODE_HASH ) ); this.externalCheckDeployedOnNetwork(LibRainDeploy.BASE, derived); @@ -182,18 +188,18 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { /// "deployed over the top" — but a `CREATE2` deploy leaves the account at /// nonce 1, while a restored etch is at nonce 0. function testDerivationRestoresCodeAtDerivedAddress() external { - assertEq(MOCK_DEPLOYED_ADDRESS_0_0_1.code, MOCK_RUNTIME_CODE_0_0_1); - assertEq(MOCK_DEPLOYED_ADDRESS_0_0_2.code, MOCK_RUNTIME_CODE_0_0_2); - assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_1), 0); - assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_2), 0); + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + assertEq(secondDeployedAddress().code, secondRuntimeCode()); + assertEq(vm.getNonce(ADDRESS_REGISTRY_DEPLOYED_ADDRESS), 0); + assertEq(vm.getNonce(secondDeployedAddress()), 0); DerivedDeploy[] memory derived = deriveDeployments(allSuites()); assertEq(derived.length, 3); - assertEq(MOCK_DEPLOYED_ADDRESS_0_0_1.code, MOCK_RUNTIME_CODE_0_0_1); - assertEq(MOCK_DEPLOYED_ADDRESS_0_0_2.code, MOCK_RUNTIME_CODE_0_0_2); - assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_1), 0); - assertEq(vm.getNonce(MOCK_DEPLOYED_ADDRESS_0_0_2), 0); + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + assertEq(secondDeployedAddress().code, secondRuntimeCode()); + assertEq(vm.getNonce(ADDRESS_REGISTRY_DEPLOYED_ADDRESS), 0); + assertEq(vm.getNonce(secondDeployedAddress()), 0); } /// The matrix MUST cover every supported network, not a subset one repo @@ -204,22 +210,25 @@ contract RainDeployVerifyChainTest is MockDeploySuites, RainDeployVerifyChain { string[] memory networks = LibRainDeploy.supportedNetworks(); for (uint256 i = 0; i < networks.length; i++) { // Live on every network except this one. - vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, MOCK_RUNTIME_CODE_0_0_1); - vm.makePersistent(MOCK_DEPLOYED_ADDRESS_0_0_1); + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); uint256 forkId = vm.createSelectFork(networks[i]); (forkId); - vm.etch(MOCK_DEPLOYED_ADDRESS_0_0_1, hex""); + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, hex""); DerivedDeploy memory derived = DerivedDeploy({ - suite: "mock-deployable-0-0-1", - deployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, - bytecodeHash: MOCK_BYTECODE_HASH_0_0_1 + suite: "address-registry-0-0-1", + deployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + bytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH }); vm.expectRevert( abi.encodeWithSelector( - NotDeployedOnNetwork.selector, networks[i], "mock-deployable-0-0-1", MOCK_DEPLOYED_ADDRESS_0_0_1 + NotDeployedOnNetwork.selector, + networks[i], + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); this.externalCheckDeployedOnNetwork(networks[i], derived); diff --git a/test/src/abstract/RainDeployVerifyOffline.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol similarity index 76% rename from test/src/abstract/RainDeployVerifyOffline.t.sol rename to test/src/abstract/RainDeployVerifySnapshot.t.sol index 0ad3cb1..e8d8c10 100644 --- a/test/src/abstract/RainDeployVerifyOffline.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -6,28 +6,28 @@ import {ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBas import {DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import { CandidateSourceMismatch, - RainDeployVerifyOffline, + RainDeployVerifySnapshot, StoredAddressMismatch, StoredCodeHashMismatch, StoredRuntimeCodeHashMismatch -} from "../../../src/abstract/RainDeployVerifyOffline.sol"; +} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {MockDeploySuites} from "../../abstract/MockDeploySuites.sol"; -import {MockDeployable} from "../../concrete/MockDeployable.sol"; +import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; +import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { - BYTECODE_HASH as MOCK_BYTECODE_HASH_0_0_1, - CREATION_CODE as MOCK_CREATION_CODE_0_0_1, - DEPLOYED_ADDRESS as MOCK_DEPLOYED_ADDRESS_0_0_1, - RUNTIME_CODE as MOCK_RUNTIME_CODE_0_0_1 -} from "../../generated/0_0_1/MockDeployable.sol"; - -/// @title RainDeployVerifyOfflineTest -/// @notice `RainDeployVerifyOffline` inherited by a exemplar repo, so the -/// inherited tests themselves are the passing case: `MockDeploySuites` + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; + +/// @title RainDeployVerifySnapshotTest +/// @notice `RainDeployVerifySnapshot` inherited by a exemplar repo, so the +/// inherited tests themselves are the passing case: `ExampleDeploySuites` /// declares two frozen releases and a candidate, and -/// `testDeployPinsInternallyConsistent` / -/// `testDeployPinsCandidateAnchoredToSource` run over them here exactly as they +/// `testSnapshotInternallyConsistent` / +/// `testSnapshotMatchesSource` run over them here exactly as they /// would in a consumer. /// /// The rest is what each group CATCHES, and — for the internal group — what it @@ -35,7 +35,7 @@ import { /// inherited tests do, through external wrappers so `vm.expectRevert` lands at /// the right call depth, with the exemplar data deliberately broken one field at /// a time. -contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOffline { +contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySnapshot { /// External wrapper for `checkInternallyConsistent` so `vm.expectRevert` /// works at the correct call depth. /// @param suite The suite to check. @@ -59,12 +59,12 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin function wrongContractCandidate() internal pure returns (DeployCandidate memory) { return DeployCandidate({ snapshot: DeploySuite({ - suite: "mock-deployable-v2-candidate", - creationCode: MOCK_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, - artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + suite: "address-registry-candidate", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", dependencies: new address[](0) }), sourceCreationCode: type(MockDeployableV2).creationCode @@ -76,12 +76,12 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin /// @return The consistent `0_0_1` suite. function consistentSuite() internal pure returns (DeploySuite memory) { return DeploySuite({ - suite: "mock-deployable-0-0-1", - creationCode: MOCK_CREATION_CODE_0_0_1, - storedDeployedAddress: MOCK_DEPLOYED_ADDRESS_0_0_1, - storedBytecodeHash: MOCK_BYTECODE_HASH_0_0_1, - storedRuntimeCode: MOCK_RUNTIME_CODE_0_0_1, - artifactPath: "test/concrete/MockDeployable.sol:MockDeployable", + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", dependencies: new address[](0) }); } @@ -95,7 +95,10 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( - StoredAddressMismatch.selector, "mock-deployable-0-0-1", address(0xdead), MOCK_DEPLOYED_ADDRESS_0_0_1 + StoredAddressMismatch.selector, + "address-registry-0-0-1", + address(0xdead), + ADDRESS_REGISTRY_DEPLOYED_ADDRESS ) ); this.externalCheckInternallyConsistent(suite); @@ -110,7 +113,10 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( - StoredCodeHashMismatch.selector, "mock-deployable-0-0-1", bytes32(uint256(1)), MOCK_BYTECODE_HASH_0_0_1 + StoredCodeHashMismatch.selector, + "address-registry-0-0-1", + bytes32(uint256(1)), + ADDRESS_REGISTRY_BYTECODE_HASH ) ); this.externalCheckInternallyConsistent(suite); @@ -128,8 +134,8 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( StoredRuntimeCodeHashMismatch.selector, - "mock-deployable-0-0-1", - MOCK_BYTECODE_HASH_0_0_1, + "address-registry-0-0-1", + ADDRESS_REGISTRY_BYTECODE_HASH, keccak256(hex"00") ) ); @@ -150,7 +156,7 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin // It really is the wrong contract: the recorded creation code is not // the creation code this repo compiles for the candidate. assertNotEq(keccak256(candidate.snapshot.creationCode), keccak256(type(MockDeployableV2).creationCode)); - assertEq(keccak256(candidate.snapshot.creationCode), keccak256(type(MockDeployable).creationCode)); + assertEq(keccak256(candidate.snapshot.creationCode), keccak256(type(AddressRegistry).creationCode)); // Every internal check passes anyway. this.externalCheckInternallyConsistent(candidate.snapshot); @@ -165,8 +171,8 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin vm.expectRevert( abi.encodeWithSelector( CandidateSourceMismatch.selector, - "mock-deployable-v2-candidate", - keccak256(MOCK_CREATION_CODE_0_0_1), + "address-registry-candidate", + keccak256(ADDRESS_REGISTRY_CREATION_CODE), keccak256(type(MockDeployableV2).creationCode) ) ); @@ -184,14 +190,14 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin /// is the ordinary state of a repo between a release and the next source /// change. `0_0_2` and the candidate are the same bytes and therefore the /// same address, and the whole set still passes. - function testVersionsSharingCreationCodeAllDerive() external { + function testSuitesSharingCreationCodeAllDerive() external { DeploySuite[] memory suites = allSuites(); assertEq(suites.length, 3); - assertEq(suites[1].storedDeployedAddress, suites[2].storedDeployedAddress); - assertEq(keccak256(suites[1].creationCode), keccak256(suites[2].creationCode)); + assertEq(suites[0].storedDeployedAddress, suites[2].storedDeployedAddress); + assertEq(keccak256(suites[0].creationCode), keccak256(suites[2].creationCode)); // Neither derivation is disturbed by the other. - this.externalCheckInternallyConsistent(suites[1]); + this.externalCheckInternallyConsistent(suites[0]); this.externalCheckInternallyConsistent(suites[2]); } @@ -209,15 +215,15 @@ contract RainDeployVerifyOfflineTest is MockDeploySuites, RainDeployVerifyOfflin // the one the creation code derives. vm.mockCall( LibRainDeploy.ZOLTU_FACTORY, - MOCK_CREATION_CODE_0_0_1, + ADDRESS_REGISTRY_CREATION_CODE, abi.encodePacked(bytes20(LibRainDeploy.ZOLTU_FACTORY)) ); vm.expectRevert( abi.encodeWithSelector( ZoltuDerivationMismatch.selector, - "mock-deployable-0-0-1", - MOCK_DEPLOYED_ADDRESS_0_0_1, + "address-registry-0-0-1", + ADDRESS_REGISTRY_DEPLOYED_ADDRESS, LibRainDeploy.ZOLTU_FACTORY ) ); diff --git a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol b/test/src/concrete/AddressRegistryDeployChain.t.sol similarity index 75% rename from test/src/concrete/AddressRegistryDeployPinsChain.t.sol rename to test/src/concrete/AddressRegistryDeployChain.t.sol index c13924d..035f1ba 100644 --- a/test/src/concrete/AddressRegistryDeployPinsChain.t.sol +++ b/test/src/concrete/AddressRegistryDeployChain.t.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.25; import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; -/// @title AddressRegistryDeployPinsChainTest +/// @title AddressRegistryDeployChainTest /// @notice Whether `AddressRegistry` is actually live, with the code this repo /// compiles, on every supported network. /// @@ -16,13 +16,13 @@ import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistry /// /// That failure is the check working. "Nothing is deployed at the address /// `LibAddressRegistry` reads" is true, it is the single most important fact -/// about these pins, and no offline assertion can discover it — a perfectly +/// about these pins, and no snapshot assertion can discover it — a perfectly /// consistent set of pins for a contract that exists nowhere passes every one /// of them. A green here would only mean nobody asked. /// -/// It is a separate contract from `AddressRegistryDeployPinsOfflineTest` +/// It is a separate contract from `AddressRegistryDeploySnapshotTest` /// precisely so that it says this and nothing more: `forge test -/// --no-match-contract Chain` still verifies everything that holds offline, +/// --no-match-contract Chain` still runs every snapshot assertion, /// whether the deployment is missing or the RPC endpoints are merely /// unreachable. -contract AddressRegistryDeployPinsChainTest is AddressRegistryDeploySuites, RainDeployVerifyChain {} +contract AddressRegistryDeployChainTest is AddressRegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol b/test/src/concrete/AddressRegistryDeploySnapshot.t.sol similarity index 78% rename from test/src/concrete/AddressRegistryDeployPinsOffline.t.sol rename to test/src/concrete/AddressRegistryDeploySnapshot.t.sol index 502f00d..40cee2f 100644 --- a/test/src/concrete/AddressRegistryDeployPinsOffline.t.sol +++ b/test/src/concrete/AddressRegistryDeploySnapshot.t.sol @@ -2,10 +2,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {RainDeployVerifyOffline} from "../../../src/abstract/RainDeployVerifyOffline.sol"; +import {RainDeployVerifySnapshot} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; -/// @title AddressRegistryDeployPinsOfflineTest +/// @title AddressRegistryDeploySnapshotTest /// @notice The deploy-pin assertions for `AddressRegistry` that need no /// network: what `LibAddressRegistryDeploy` records is what the creation code /// this repo compiles derives, and the candidate is a snapshot of that source @@ -19,5 +19,5 @@ import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistry /// /// Both assertions are inherited. There is nothing to write here, which is the /// point: `AddressRegistryDeploySuites` says which versions exist and -/// `RainDeployVerifyOffline` says what is true of them. -contract AddressRegistryDeployPinsOfflineTest is AddressRegistryDeploySuites, RainDeployVerifyOffline {} +/// `RainDeployVerifySnapshot` says what is true of them. +contract AddressRegistryDeploySnapshotTest is AddressRegistryDeploySuites, RainDeployVerifySnapshot {} diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index 19ae7e2..ca445ab 100644 --- a/test/src/lib/GeneratedSnapshotShape.t.sol +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -22,7 +22,7 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; /// /// Values are deliberately not asserted. A solc or optimiser change moves every /// literal without changing anything here, and a wrong literal is caught -/// immediately by the group 1 derivation checks in `RainDeployVerifyOffline`. +/// immediately by the group 1 derivation checks in `RainDeployVerifySnapshot`. contract GeneratedSnapshotShapeTest is Test { /// The artifact for the generated candidate snapshot, which carries its AST. string constant ARTIFACT = "out/candidate/AddressRegistry.sol/AddressRegistry.json"; From befb78d9c512567aeb29ae89c61fcc348b9dfeb5 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 20:25:24 +0000 Subject: [PATCH 18/29] refactor(snapshot): bump rain-sol-codegen to 0.1.6 and use its filePrefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rainlanguage/rain.sol.codegen#34 deleted the paragraph in `filePrefix` that explained a generated file is committed because of a circular dependency between a contract and its generated file. That paragraph is why `writeAliasLib` hand-rolled its own header: the claim is true of a snapshot and false of an alias lib, which is committed because it IS the stable source consumers import, so emitting it would have put a false statement into generated output. 0.1.6 is the release carrying that deletion, and with it gone there is nothing left in the prefix that varies by caller. So the hand-rolled SPDX, pragma and AUTOGENERATED lines go, the comment defending them goes, and the `REUSE-Ignore` block that existed only because the SPDX lines were written out by hand goes with it — `reuse lint` is clean without it, 50/50 files. The emitted bytes do not move: `src/lib/LibAddressRegistryDeploy.sol` regenerates byte for byte identical, which is the check that the prefix and the hand-rolled header were the same string. `src/generated/candidate/ AddressRegistry.sol` loses the five deleted comment lines and nothing else — `BYTECODE_HASH`, `DEPLOYED_ADDRESS`, `CREATION_CODE` and `RUNTIME_CODE` are unchanged, so no address or code hash moves and no consumer's pins change. `forge soldeer update` rather than `install`, and the versioned import prefixes in `LibRainDeploySnapshot` move `0.1.4` -> `0.1.6` with the stale remapping and dependency directory dropped. `LibSnapshot.sol` is absent from 0.1.6, which is correct: it moved into this repo as `LibRainDeploySnapshot` in 589686c. Co-Authored-By: Claude Opus 5 (1M context) --- foundry.toml | 2 +- remappings.txt | 2 +- soldeer.lock | 8 +++--- src/generated/candidate/AddressRegistry.sol | 5 ---- src/lib/LibRainDeploySnapshot.sol | 30 +++++---------------- 5 files changed, 13 insertions(+), 34 deletions(-) diff --git a/foundry.toml b/foundry.toml index aae55d1..8302693 100644 --- a/foundry.toml +++ b/foundry.toml @@ -45,7 +45,7 @@ fs_permissions = [ [dependencies] forge-std = "1.16.1" -rain-sol-codegen = "0.1.4" +rain-sol-codegen = "0.1.6" [soldeer] recursive_deps = false diff --git a/remappings.txt b/remappings.txt index d276696..46ed0ce 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,2 +1,2 @@ forge-std-1.16.1/=dependencies/forge-std-1.16.1/ -rain-sol-codegen-0.1.4/=dependencies/rain-sol-codegen-0.1.4/ +rain-sol-codegen-0.1.6/=dependencies/rain-sol-codegen-0.1.6/ diff --git a/soldeer.lock b/soldeer.lock index d0ecd02..7cfb9a7 100644 --- a/soldeer.lock +++ b/soldeer.lock @@ -7,7 +7,7 @@ integrity = "60e55d10150354ca4a1e2985c5456c834b92b82ef85ab0e1d92a7786cddbd219" [[dependencies]] name = "rain-sol-codegen" -version = "0.1.4" -url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_4_15-07-2026_13:56:09_rain.sol.zip" -checksum = "88e1d8df372c86dbfa45266c2bb53e9ceb95284dabf97623fde1281d350151a2" -integrity = "65422e32cf8ab1c75d345bbf768774467cd920cba03234114a412881dc4a35e7" +version = "0.1.6" +url = "https://soldeer-revisions.s3.amazonaws.com/rain-sol-codegen/0_1_6_13-08-2026_19:05:33_rain.sol.zip" +checksum = "906ebec1dff49612802ce04db626827aba348f4efd174fc7cb483f7d9ce8a06d" +integrity = "2e911fdf161eed28e1d94c50a1cc2ee0d353aa4c9d1a4dc2bdbfa0ac4f3b1de7" diff --git a/src/generated/candidate/AddressRegistry.sol b/src/generated/candidate/AddressRegistry.sol index d87bd99..4d8b58e 100644 --- a/src/generated/candidate/AddressRegistry.sol +++ b/src/generated/candidate/AddressRegistry.sol @@ -4,11 +4,6 @@ pragma solidity ^0.8.25; // THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. -// It is committed to the repository because there is a circular dependency -// between the contract and its generated file. The contract needs the -// generated file to exist so that it can compile, and the generated file -// needs the contract to exist so that it can be compiled. - /// @dev Hash of the known bytecode. bytes32 constant BYTECODE_HASH = bytes32(0xd9a6a2f03c1e1851becfedba819a436dcccc81c40ac8df02be6815f0b261d042); diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index b0f8164..c02c650 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.1/src/Vm.sol"; -import {LibCodeGen} from "rain-sol-codegen-0.1.4/src/lib/LibCodeGen.sol"; -import {LibFs} from "rain-sol-codegen-0.1.4/src/lib/LibFs.sol"; +import {LibCodeGen} from "rain-sol-codegen-0.1.6/src/lib/LibCodeGen.sol"; +import {LibFs} from "rain-sol-codegen-0.1.6/src/lib/LibFs.sol"; import {LibRainDeploy} from "./LibRainDeploy.sol"; /// Thrown when `[package].version` is not strict `X.Y.Z`. A version like @@ -317,20 +317,9 @@ library LibRainDeploySnapshot { /// path ARE derived, because `LibDeploy` at `src/lib/` is /// mechanical. /// - /// ## This owns its header, and `LibCodeGen.filePrefix` cannot supply it - /// - /// `filePrefix` hardcodes a paragraph explaining that the file is committed - /// because of a circular dependency between a contract and its generated - /// file. That is true of a snapshot and FALSE of an alias lib, which is - /// committed because it IS the stable source consumers import. Emitting it - /// here would put a false statement into generated output, so the header is - /// written out instead — the SPDX lines included, which is why this - /// function carries a REUSE ignore. - /// - /// The upstream fix is to split `filePrefix` into the invariant part (SPDX, - /// pragma, `AUTOGENERATED ... DO NOT EDIT BY HAND`) and a caller-supplied - /// rationale, or to take that rationale as a parameter. Then both this and - /// the snapshot writer use it and nothing here restates anything. + /// The header comes from `LibCodeGen.filePrefix`, the same one `LibFs` + /// gives a snapshot, so an alias lib and a snapshot say they are generated + /// in identical words and neither restates the other. /// @param vm The Vm instance for file operations. /// @param contractName The contract the snapshot describes. /// @param constantPrefix The prefix for the emitted constants, e.g. @@ -344,21 +333,16 @@ library LibRainDeploySnapshot { string memory libraryName = string.concat("Lib", contractName, "Deploy"); string memory path = string.concat("src/lib/", libraryName, ".sol"); - // REUSE-IgnoreStart (the SPDX lines below are the header EMITTED into - // the generated lib, not this file's own license) //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile( path, string.concat( - "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n" - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n" - "pragma solidity ^0.8.25;\n\n" - "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibCodeGen.filePrefix(), + "\n", aliasImportBlock(contractName, constantPrefix, dir), aliasLibraryBlock(contractName, constantPrefix, libraryName) ) ); - // REUSE-IgnoreEnd return path; } From a94040f7e506497981a9bc829c711de962596b07 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 20:56:30 +0000 Subject: [PATCH 19/29] feat(verify): zero root for rollout, and chain checks only what released The root authority is address(0) until rollout picks one. Every address derived from it is zero and every read against one reverts, so the state is loud rather than dangerous, and needs no machinery to declare itself. The chain matrix now runs over releasedSuites() rather than allSuites(). A release is a deployment that happened, so "it is on every network" is a claim about it that is either true or a defect. The candidate is what the next release will be, ordinarily ahead of anything on chain -- demanding it be live asserts something false by design, and made red the normal state of a repo adopting this. That puts the weight on releasedSuites() naming every release, and it is hand written. checkFrozenSnapshotsReleased holds it against the append-only src/generated// record, so a forgotten tag fails loudly instead of leaving a check with no subject. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 85 +++++++----- src/abstract/RainDeployVerifyChain.sol | 40 +++++- src/abstract/RainDeployVerifySnapshot.sol | 76 ++++++++++- src/concrete/AddressRegistry.sol | 21 +-- src/generated/candidate/AddressRegistry.sol | 8 +- src/lib/LibRainDeploySnapshot.sol | 129 +++++++++++++++--- test/src/abstract/RainDeployVerifyChain.t.sol | 83 ++++++++++- .../abstract/RainDeployVerifySnapshot.t.sol | 55 ++++++++ test/src/lib/LibRainDeploySnapshot.t.sol | 119 ++++++++++++++++ 9 files changed, 541 insertions(+), 75 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 285396a..38275b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,34 +105,37 @@ nothing else. `ADDRESS_REGISTRY_ROOT` is a compile-time constant and therefore part of the creation code, so changing it moves the deterministic address and code hash. +`ADDRESS_REGISTRY_ROOT` is `address(0)` during rollout. Nothing calls from the +zero address, so no name can be bound, and `get` reverts on every name that is +not bound — a registry compiled under this root answers every read with a revert +and can never answer one with an address. Setting a real root later is an +ordinary source change that moves the creation code, the address, the code hash +and the snapshot together. + **`src/lib/LibAddressRegistryDeploy.sol`** — those pins, derived from the creation code this repo compiles under this repo's own settings and checked against it by `AddressRegistryDeploySnapshotTest`. GENERATED by -`script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. No -`/` snapshot is frozen while the root is a placeholder, because that -directory is append-only. +`script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. ### Generated snapshots, and the assertions that specify their shape Every deploy snapshot in this repo is GENERATED and committed. There is no -hand-maintained hex anywhere: - -- `src/generated/candidate/AddressRegistry.sol` — the real deploy record, from - `forge script script/Build.sol`. -- `test/generated/0_0_1/MockDeployable.sol` and `0_0_2/MockDeployableV2.sol` — - the records the verification abstracts are exercised against, from - `forge script script/BuildTestSnapshots.sol`. Under `test/` because - `.soldeerignore` excludes it, so mock records never ship in the package. - -Both scripts call the SAME generator, -`LibRainDeploySnapshot.writeSnapshot(vm, outputRoot, dir, contractName, creationCode)`. -It is parameterised on the declaration and the output root rather than -special-cased, so test records come off the production code path and the shape -assertions describe the emitter that writes real ones. - -A compiler or optimiser change is therefore "run the two scripts, commit". Never +hand-maintained hex anywhere: `src/generated/candidate/AddressRegistry.sol` is +the deploy record, from `forge script script/Build.sol`. + +A compiler or optimiser change is therefore "run the script, commit". Never hand-edit a generated file. +`src/generated//` directories are the FROZEN record: what each release +deployed, written once by `cutRelease()` and never again. That tree is the only +description of what this repo has released that cannot fall behind, which is why +`RainDeployVerifySnapshot` checks the hand-written `releasedSuites()` against +it. `LibRainDeploySnapshot.frozenSnapshotPaths` is the walk: every file inside a +release-tag directory, where a release tag is exactly what `tagForVersion` +produces — so `candidate/`, a scratch directory and a `0_1_7-rc1` nobody could +have frozen all fall out under the same rule, and there is no name to remember +to exclude. + **`GeneratedSnapshotShapeTest` is the specification of the shape.** It asserts named properties against the compiler's AST — not against a second reference file, so there is no question of that file's provenance, and not against source @@ -219,7 +222,7 @@ calldata under a zero salt, so the address is a pure function of it, and running it once locally gives the runtime code and its hash. The address, code hash and runtime code a generated file records are checked OUTPUTS. -Three groups, sorted by what they are anchored to: +Four groups, sorted by what they are anchored to: 1. **Internal to the recorded set** (`RainDeployVerifySnapshot`) — what a version records is what its own creation code derives. Catches a set @@ -231,13 +234,28 @@ Three groups, sorted by what they are anchored to: a wrong-contract snapshot. Candidate only, because a released tag is MEANT to diverge from current source; there is no field on a released version to spell it, so it cannot be opted into or out of. -3. **Anchored to chain** (`RainDeployVerifyChain`) — across - `supportedNetworks()`, every version's derived address carries code with its - derived code hash. The only check that catches "never deployed" or "not there - any more", neither of which the repo can hold: both go false with nobody - touching it. - -Group 3 lives in its own contract so an unreachable RPC endpoint fails only it, +3. **Anchored to the record** (`RainDeployVerifySnapshot`) — every file in the + append-only `src/generated//` tree is declared by a released suite, + matched by the address that file's creation code derives. `releasedSuites()` + is hand written and everything anchored to a chain reads it, so a frozen tag + nobody added to it is a release that quietly drops out of every check there + is. Matched against RELEASED suites only: a release and the candidate are + byte-identical from the moment the release is cut, so matching the whole + declaration would let the candidate declare a release. +4. **Anchored to chain** (`RainDeployVerifyChain`) — across + `supportedNetworks()`, every RELEASED version's derived address carries code + with its derived code hash. The only check that catches "never deployed" or + "not there any more", neither of which the repo can hold: both go false with + nobody touching it. + +Group 4 is released-only for the mirror image of group 2's reason. A release IS +a deployment that happened; the candidate is what the next release will be, and +between releases it is ordinarily ahead of anything on chain, so demanding it be +live asserts something false by design. Neither exemption is a field a caller +can set. Group 3 is what makes group 4's scope complete — a release group 4 is +never handed is a release it cannot fail on. + +Group 4 lives in its own contract so an unreachable RPC endpoint fails only it, never the snapshot assertions — `forge test --no-match-contract Chain` is the whole snapshot gate, and nothing reachable from those contracts forks anything. @@ -283,14 +301,19 @@ expected addresses, expected code hashes, and dependency lists. - **Deploy, then verify, then tag** — in that order, and they are three separate things. `script/Deploy.sol` broadcasts `AddressRegistry` to every network in `supportedNetworks()`, dispatched by hand through - `.github/workflows/manual-sol-artifacts.yaml`. Only then can - `AddressRegistryDeployChainTest` pass, and only then is there a deployment for - `rainix-tag-release` to verify pins against — it verifies and publishes, it - never broadcasts. Broadcasting is key custody and real money, so it is + `.github/workflows/manual-sol-artifacts.yaml`. Only then is there a deployment + for `rainix-tag-release` to verify pins against — it verifies and publishes, + it never broadcasts. Broadcasting is key custody and real money, so it is `workflow_dispatch` and nothing else. Deploying is idempotent: a network that already has the code is skipped, so a partial run is fixed by running it again. + `AddressRegistryDeployChainTest` is what verifies it, and it checks the + RELEASED suites. Nothing is released yet, so it has nothing to check and forks + nothing. It gets a subject the moment a release is frozen and declared — from + then on it is red until that release is live on every supported network, which + is why the deploy comes first. + ## License DecentraLicense 1.0 (LicenseRef-DCL-1.0). All source files must have SPDX diff --git a/src/abstract/RainDeployVerifyChain.sol b/src/abstract/RainDeployVerifyChain.sol index f87833f..ce84877 100644 --- a/src/abstract/RainDeployVerifyChain.sol +++ b/src/abstract/RainDeployVerifyChain.sol @@ -32,7 +32,7 @@ error CodeHashMismatchOnNetwork( /// @title RainDeployVerifyChain /// @notice The only deploy-pin assertions anchored to something outside the /// repo: across every network in `LibRainDeploy.supportedNetworks()`, every -/// declared suite's derived address carries code with its derived code hash. +/// RELEASED suite's derived address carries code with its derived code hash. /// /// This is the only group that can catch a suite that never deployed to a /// network, or that is not there any more. Neither is a fact the repo can hold: @@ -40,8 +40,32 @@ error CodeHashMismatchOnNetwork( /// chains of five, a chain added to `supportedNetworks()` after a release that /// therefore never got it, a deploy that silently failed. /// +/// ## Released only, for the same reason source anchors the candidate only +/// +/// A release IS a deployment that happened. That is what its recorded bytes +/// describe and why they are frozen, so "it is on every network" is a claim +/// about it that is either true or a defect. The candidate is what the NEXT +/// release will be: between releases it is ordinarily ahead of anything on +/// chain, and a repo whose source has moved since its last deploy is the +/// normal state of a repo, not a fault in it. Demanding the candidate be live +/// asserts something false by design. +/// +/// The two exemptions are the same shape from opposite ends — +/// `RainDeployVerifySnapshot` anchors the candidate to source and never a +/// release, because a release is meant to have diverged from source; this +/// anchors releases to the chain and never the candidate, because the +/// candidate is meant to be ahead of the chain. Neither is a field a caller +/// can set: there is nothing to opt a suite into or out of. +/// +/// That puts the whole weight on `releasedSuites()` naming every release. A +/// frozen tag missing from it would be a release nothing here is ever handed, +/// and a check that is never handed a subject cannot fail on it — so +/// `RainDeployVerifySnapshot` checks that declaration against the append-only +/// `src/generated//` record. Without that, scoping to releases would be a +/// way to make this contract quiet rather than correct. +/// /// The matrix is suites by networks and is generated from both, so a new -/// network leaves no suite unchecked and a new suite is checked on every +/// network leaves no suite unchecked and a new release is checked on every /// network from the moment it is declared. There are deliberately no per-chain /// or per-suite functions to add. /// @@ -84,6 +108,14 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { /// expectation. /// @param derived The derivation of every suite to check. function checkDeployedOnSupportedNetworks(DerivedDeploy[] memory derived) internal { + // Nothing to check is not a reason to touch five RPC endpoints. Forking + // to check nothing turns an outage into the failure of an assertion + // that has no subject, which is the one failure this contract is + // supposed to be legible against. + if (derived.length == 0) { + return; + } + string[] memory networks = LibRainDeploy.supportedNetworks(); for (uint256 i = 0; i < networks.length; i++) { // createSelectFork returns a fork id that is not needed here; bind @@ -96,9 +128,9 @@ abstract contract RainDeployVerifyChain is RainDeployVerifyBase { } } - /// Every declared suite MUST be live, with the code its creation code + /// Every RELEASED suite MUST be live, with the code its creation code /// produces, on every supported network. function testSuitesLiveOnEverySupportedNetwork() external { - checkDeployedOnSupportedNetworks(deriveDeployments(allSuites())); + checkDeployedOnSupportedNetworks(deriveDeployments(releasedSuites())); } } diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index e1a5d8b..c1e9783 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -4,6 +4,8 @@ pragma solidity ^0.8.25; import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol"; import {DeployCandidate, DeploySuite} from "./RainDeploySuitesBase.sol"; +import {LibRainDeploy} from "../lib/LibRainDeploy.sol"; +import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol"; /// Thrown when the deploy address recorded for a version is not the address its /// own creation code derives. @@ -36,10 +38,18 @@ error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, by /// contract the candidate claims to be. error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash); +/// Thrown when a file in the frozen record is declared by no released suite. +/// The record is append-only, so this never goes away by itself: a release the +/// declaration missed is a release the chain group never asks about, and the +/// chain group passing means nothing for it. +/// @param path The frozen record file no released suite declares. +error FrozenSnapshotNotReleased(string path); + /// @title RainDeployVerifySnapshot /// @notice Every deploy-pin assertion that needs no network, for every suite -/// a repo declares. Two groups, which catch different things and are documented -/// as such because it is easy to read the first as covering the second. +/// a repo declares. Three groups, which catch different things and are +/// documented as such because it is easy to read the first as covering the +/// second. /// /// **Internal to the recorded set.** The address a suite's creation code /// derives is the address it records, the code hash that creation code produces @@ -60,9 +70,18 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// source, so anchoring one to source asserts something that is false by /// design. /// -/// Neither group can catch a suite that was never deployed, or that is no +/// **Anchored to the record.** Every file in the frozen record — the +/// append-only `src/generated//` directories — is declared by a released +/// suite. This is the one check that is about the DECLARATION rather than about +/// what a declared suite records, and it exists because everything anchored to +/// a chain reads `releasedSuites()`, which a human maintains. A release missing +/// from it is not caught anywhere else, by anything: it simply stops being +/// checked, and every check there is stays green. +/// +/// None of the three can catch a suite that was never deployed, or that is no /// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a -/// substitute for it. +/// substitute for it — but the record check is what makes its scope complete, +/// because a release it is never handed is a release it cannot fail on. abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { /// Checks one suite against itself: derive from its creation code, then /// require everything it records to agree with the derivation. @@ -84,6 +103,46 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { } } + /// Checks the frozen record against the released declaration: every file in + /// the record is declared by a released suite. + /// + /// `releasedSuites()` is hand written, and everything anchored to a chain + /// reads it. A frozen tag nobody added to it is therefore not a missing + /// entry that shows up as a failure somewhere — it is a release that drops + /// out of every check there is, silently and permanently, while the whole + /// suite stays green. The record is the only thing that can say it + /// happened, so the declaration is checked against the record. + /// + /// Matched against the RELEASED suites alone, deliberately. A release and + /// the rolling candidate are byte-identical from the moment the release is + /// cut until source next moves, so a match against every declared suite + /// would let the candidate declare a frozen release — and the candidate is + /// exactly what the chain group does not check. + /// + /// The match is by derived address, which is a pure function of the + /// creation code and is what the record records. A suite whose creation + /// code derives the address in a file IS that file's release. Nothing is + /// matched by name, which would assert only that a convention was followed. + /// @param paths The frozen record's files. + /// @param released The declared released suites. + function checkFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) internal view { + for (uint256 i = 0; i < paths.length; i++) { + string memory record = vm.readFile(paths[i]); + + bool declared = false; + for (uint256 j = 0; j < released.length; j++) { + if (vm.contains(record, vm.toString(LibRainDeploy.zoltuAddress(released[j].creationCode)))) { + declared = true; + break; + } + } + + if (!declared) { + revert FrozenSnapshotNotReleased(paths[i]); + } + } + } + /// Checks the candidate against the source this repo compiles. /// @param candidate The candidate to check. function checkAnchoredToSource(DeployCandidate memory candidate) internal pure { @@ -110,4 +169,13 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { function testSnapshotMatchesSource() external pure { checkAnchoredToSource(candidateSuite()); } + + /// Every release in the frozen record MUST be declared, so that the set the + /// chain group checks is every release this repo has ever cut rather than + /// the ones somebody remembered to list. + function testEveryFrozenSnapshotIsReleased() external view { + checkFrozenSnapshotsReleased( + LibRainDeploySnapshot.frozenSnapshotPaths(vm, LibRainDeploySnapshot.LIB_FS_ROOT), releasedSuites() + ); + } } diff --git a/src/concrete/AddressRegistry.sol b/src/concrete/AddressRegistry.sol index b1e6581..33a44a7 100644 --- a/src/concrete/AddressRegistry.sol +++ b/src/concrete/AddressRegistry.sol @@ -4,17 +4,22 @@ pragma solidity =0.8.25; import {IAddressRegistryV1} from "../interface/IAddressRegistryV1.sol"; -/// @dev PLACEHOLDER ROOT AUTHORITY. THIS IS NOT A REAL ROOT. -/// -/// The only account that may bind a name. It is a compile-time constant, not -/// storage, so it can never be rotated, and it is part of the creation code, so +/// @dev The only account that may bind a name. A compile-time constant rather +/// than storage, so it can never be rotated, and part of the creation code, so /// changing it changes the deterministic deploy address and code hash of /// `AddressRegistry` on every network. /// -/// A human MUST replace this value with the intended root before any deploy-pin -/// snapshot is generated for this contract, and the pins in `rain-deploy`'s -/// `LibAddressRegistry` MUST be regenerated from the resulting creation code. -address constant ADDRESS_REGISTRY_ROOT = address(0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD); +/// Zero during rollout. Nothing calls from the zero address, so no name can be +/// bound while root is zero, and `get` reverts on every name that is not bound +/// — so a registry compiled under this root answers every read with a revert +/// and cannot answer one with an address. There is no state in which a consumer +/// silently resolves something wrong from it: a zero root makes the registry +/// inert, loudly, in every direction. +/// +/// Setting a real root is an ordinary source change. It moves the creation +/// code, and therefore the deploy address, the code hash, the snapshot +/// `script/Build.sol` generates and the release that carries them. +address constant ADDRESS_REGISTRY_ROOT = address(0); /// @title AddressRegistry /// @notice The whole of `IAddressRegistryV1`: an immutable root authority binds diff --git a/src/generated/candidate/AddressRegistry.sol b/src/generated/candidate/AddressRegistry.sol index 4d8b58e..962c396 100644 --- a/src/generated/candidate/AddressRegistry.sol +++ b/src/generated/candidate/AddressRegistry.sol @@ -5,16 +5,16 @@ pragma solidity ^0.8.25; // THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. /// @dev Hash of the known bytecode. -bytes32 constant BYTECODE_HASH = bytes32(0xd9a6a2f03c1e1851becfedba819a436dcccc81c40ac8df02be6815f0b261d042); +bytes32 constant BYTECODE_HASH = bytes32(0xef835570415a69bdf98ea5cacd8c4d2caba4730d06c2218bf102cb4473f4ea73); /// @dev The deterministic deploy address of the contract when deployed via /// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0x0B8CAaDADF7c53a1b0Af8A7A8E7F3ca90DE517d6); +address constant DEPLOYED_ADDRESS = address(0x25aC2b82915f191dbE64e65BAeDDD68b97b68fe1); /// @dev The creation bytecode of the contract. bytes constant CREATION_CODE = - hex"6080604052348015600e575f80fd5b5061026a8061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b61004636600461020d565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b610087610082366004610224565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f6020828403121561021d575f80fd5b5035919050565b5f8060408385031215610235575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461025f575f80fd5b80915050925092905056"; + hex"6080604052348015600e575f80fd5b506102558061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b6100463660046101f8565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61008761008236600461020f565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b331561012b576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff811661017b576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f60208284031215610208575f80fd5b5035919050565b5f8060408385031215610220575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461024a575f80fd5b80915050925092905056"; /// @dev The runtime bytecode of the contract. bytes constant RUNTIME_CODE = - hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b61004636600461020d565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b610087610082366004610224565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b3373deaddeaddeaddeaddeaddeaddeaddeaddeaddead14610140576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff8116610190576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f6020828403121561021d575f80fd5b5035919050565b5f8060408385031215610235575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461025f575f80fd5b80915050925092905056"; + hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c80638eaa6ac014610038578063d22057a914610074575b5f80fd5b61004b6100463660046101f8565b610089565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b61008761008236600461020f565b6100f1565b005b5f8181526020819052604090205473ffffffffffffffffffffffffffffffffffffffff16806100ec576040517fe9b7924f000000000000000000000000000000000000000000000000000000008152600481018390526024015b60405180910390fd5b919050565b331561012b576040517f8c7257830000000000000000000000000000000000000000000000000000000081523360048201526024016100e3565b73ffffffffffffffffffffffffffffffffffffffff811661017b576040517f657fb0ff000000000000000000000000000000000000000000000000000000008152600481018390526024016100e3565b5f8281526020819052604080822080547fffffffffffffffffffffffff00000000000000000000000000000000000000001673ffffffffffffffffffffffffffffffffffffffff85169081179091559051909184917f1082cda15f9606da555bb7e9bf4eeee2f8e34abe85d3924bf9bacb716f8feca69190a35050565b5f60208284031215610208575f80fd5b5035919050565b5f8060408385031215610220575f80fd5b82359150602083013573ffffffffffffffffffffffffffffffffffffffff8116811461024a575f80fd5b80915050925092905056"; diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index c02c650..2cc5d0a 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -87,41 +87,71 @@ library LibRainDeploySnapshot { return tagForVersion(vm.parseTomlString(vm.readFile("foundry.toml"), ".package.version")); } - /// The directory form of a release version, refusing anything that is not - /// strict `X.Y.Z`. + /// Whether `subject` is three non-empty runs of digits joined by exactly + /// two `separator`s. /// - /// Split from `deployTag` so the refusal is reachable without writing a - /// `foundry.toml`: a guard that cannot be exercised is a guard nobody knows - /// works. `0.1.7-rc1` maps to `0_1_7-rc1`, a directory the append-only gate - /// ignores forever — an orphan snapshot nothing protects — so it is refused - /// rather than frozen. - /// @param version The version string, e.g. `0.1.7`. - /// @return The tag, e.g. `0_1_7`. - function tagForVersion(string memory version) internal pure returns (string memory) { - bytes memory versionBytes = bytes(version); + /// The ONE definition of the release-version shape. It is asked with `.` + /// for a version out of `foundry.toml` and with `_` for the directory that + /// version freezes to, so what `tagForVersion` accepts and what + /// `frozenSnapshotPaths` recognises as a release cannot drift apart. Two + /// spellings of one rule is how a version becomes freezable to a directory + /// the record then ignores. + /// @param subject The string to test. + /// @param separator The component separator: `.` for a version, `_` for a + /// tag. + /// @return Whether it has the shape. + function isStrictTriple(string memory subject, bytes1 separator) internal pure returns (bool) { + bytes memory subjectBytes = bytes(subject); - // Digits and exactly two dots, no leading or trailing dot, no empty - // component. - uint256 dots = 0; + uint256 separators = 0; uint256 digitsInComponent = 0; - for (uint256 i = 0; i < versionBytes.length; i++) { - bytes1 char = versionBytes[i]; - if (char == ".") { + for (uint256 i = 0; i < subjectBytes.length; i++) { + bytes1 char = subjectBytes[i]; + if (char == separator) { + // No leading separator, and no empty component. if (digitsInComponent == 0) { - revert UnreleasableVersion(version); + return false; } - dots++; + separators++; digitsInComponent = 0; } else if (char >= "0" && char <= "9") { digitsInComponent++; } else { - revert UnreleasableVersion(version); + return false; } } - if (dots != 2 || digitsInComponent == 0) { + // Exactly two separators, and no trailing one. + return separators == 2 && digitsInComponent > 0; + } + + /// Whether a directory under a record root is a frozen release. + /// + /// A release directory is named by `tagForVersion`, so being tag shaped is + /// what makes a directory a release. The rolling `candidate/` is not a + /// release and is excluded by the same rule that admits every real one, + /// rather than by a name this would have to remember to exclude. + /// @param dir The directory name, e.g. `0_1_7`. + /// @return Whether it is a release tag. + function isTag(string memory dir) internal pure returns (bool) { + return isStrictTriple(dir, "_"); + } + + /// The directory form of a release version, refusing anything that is not + /// strict `X.Y.Z`. + /// + /// Split from `deployTag` so the refusal is reachable without writing a + /// `foundry.toml`: a guard that cannot be exercised is a guard nobody knows + /// works. `0.1.7-rc1` maps to `0_1_7-rc1`, a directory `isTag` does not + /// recognise and the record therefore ignores forever — an orphan snapshot + /// nothing protects — so it is refused rather than frozen. + /// @param version The version string, e.g. `0.1.7`. + /// @return The tag, e.g. `0_1_7`. + function tagForVersion(string memory version) internal pure returns (string memory) { + if (!isStrictTriple(version, ".")) { revert UnreleasableVersion(version); } + bytes memory versionBytes = bytes(version); bytes memory tagBytes = new bytes(versionBytes.length); for (uint256 i = 0; i < versionBytes.length; i++) { // forge-lint: disable-next-line(unsafe-typecast) @@ -163,6 +193,63 @@ library LibRainDeploySnapshot { /// `LibFs.pathForContract` hardcodes it. string constant LIB_FS_ROOT = "src/generated"; + /// Every file in the FROZEN record: everything inside a release-tag + /// directory under `root`. + /// + /// The record is the directory tree, not a list. `freeze` writes one + /// directory per release and removes none, so the tree is the complete + /// history of what a repo has released and is the only description of it + /// that cannot fall behind. Anything that reads a repo's own declaration of + /// what it has released has to be checkable against this, or a release + /// nobody declared is a release nothing verifies. + /// + /// Two rules, and both are the shape `freeze` writes: + /// + /// - the directory is tag shaped (`isTag`), which is what a release + /// directory is named by. `candidate/` is not a release and falls out + /// here, as does a scratch directory a test or a human left behind. + /// - the entry is a file directly inside it. Everything in a release + /// directory belongs to that release's record — there is no extension to + /// filter on, because nothing else has any business being in there. + /// @param vm The Vm instance for file operations. + /// @param root The record root — `LIB_FS_ROOT` for a repo's real record. + /// @return paths Every frozen record file. + function frozenSnapshotPaths(Vm vm, string memory root) internal view returns (string[] memory paths) { + // A repo with no generated directory at all has released nothing. That + // is a real state — it is this repo's own, before its first release — + // rather than a missing file to fail on. + if (!vm.exists(root)) { + return new string[](0); + } + + Vm.DirEntry[] memory entries = vm.readDir(root, 2); + + string[] memory found = new string[](entries.length); + uint256 count = 0; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].isDir || entries[i].depth != 2) { + continue; + } + string[] memory components = vm.split(entries[i].path, "/"); + string memory tag = components[components.length - 2]; + if (!isTag(tag)) { + continue; + } + // Rebuilt from `root` rather than taken from the entry, which + // `readDir` spells absolutely. A record path is compared against a + // repo's own paths and named in a failure, so it has to be the + // repo-relative one every other path here is — and at depth 2 the + // last two components are the whole of what is below `root`. + found[count] = string.concat(root, "/", tag, "/", components[components.length - 1]); + count++; + } + + paths = new string[](count); + for (uint256 i = 0; i < count; i++) { + paths[i] = found[i]; + } + } + /// Generate one snapshot for one contract, under an arbitrary output root. /// /// The root is a parameter because a repo generates real deploy records diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index e92efb9..c3ed7a2 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../../src/abstract/RainDeploySuitesBase.sol"; import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; import { CodeHashMismatchOnNetwork, @@ -22,7 +23,10 @@ import { /// @notice `RainDeployVerifyChain` inherited by a exemplar repo whose versions /// are made live on every network by `setUp`, so the inherited /// `testSuitesLiveOnEverySupportedNetwork` is the passing case: it forks -/// every network `supportedNetworks()` returns and finds all three suites. +/// every network `supportedNetworks()` returns and finds both released suites. +/// The candidate is etched too, so nothing here depends on whether the matrix +/// happens to reach it — `RainDeployVerifyChainCandidateTest` below is what +/// says it does not. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the /// exemplar at some real deployment in another repo. A real one would make this @@ -88,8 +92,8 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain } /// EVERY suite MUST be checked, not just the first one the matrix - /// reaches. The version missing here is the second and third, so a matrix - /// that stopped after the first version would pass. + /// reaches. The version missing here is the LAST one, so a matrix that + /// stopped after the first version would pass. function testChainNotDeployedRevertsForALaterSuite() external { vm.revokePersistent(secondDeployedAddress()); @@ -235,3 +239,76 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain } } } + +/// @title RainDeployVerifyChainCandidateTest +/// @notice A repo between releases: source has moved on, so the candidate is a +/// different contract from the last release and is deployed nowhere. The +/// inherited matrix MUST pass anyway. +/// +/// This is the ordinary state of a deploy repo, not a fault in one. A candidate +/// is what the NEXT release will be; requiring it to already be on chain asks +/// the repo to have deployed something it has not released, and would make +/// every repo permanently red for as long as its source was ahead of its last +/// deploy — which is most of the time. +/// +/// It needs its own declaration because `ExampleDeploySuites` cannot say it: +/// its candidate shares creation code with a release, so every address it names +/// is live whichever scope the matrix uses, and the two are indistinguishable +/// there. Here the released suite is made live and the candidate deliberately +/// is not, at a DIFFERENT address, which is the only configuration that can +/// tell them apart. +contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "second-address-candidate", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// The RELEASE is live everywhere. The candidate is not touched. + function setUp() external { + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + } + + /// The matrix MUST pass with the candidate on no network at all, and the + /// candidate MUST really be absent — otherwise this passes for the wrong + /// reason and says nothing about the scope. + function testChainIgnoresAnUndeployedCandidate() external { + address candidateAddress = LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); + assertNotEq(candidateAddress, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + assertEq(candidateAddress.code.length, 0); + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + } + + this.testSuitesLiveOnEverySupportedNetwork(); + } +} diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index e8d8c10..4b6478e 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -6,11 +6,13 @@ import {ZoltuDerivationMismatch} from "../../../src/abstract/RainDeployVerifyBas import {DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import { CandidateSourceMismatch, + FrozenSnapshotNotReleased, RainDeployVerifySnapshot, StoredAddressMismatch, StoredCodeHashMismatch, StoredRuntimeCodeHashMismatch } from "../../../src/abstract/RainDeployVerifySnapshot.sol"; +import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; @@ -50,6 +52,59 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn checkAnchoredToSource(candidate); } + /// External wrapper for `checkFrozenSnapshotsReleased` so `vm.expectRevert` + /// works at the correct call depth. + /// @param paths The frozen record's files. + /// @param released The declared released suites. + function externalCheckFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) + external + view + { + checkFrozenSnapshotsReleased(paths, released); + } + + /// The real generated snapshot, standing in for a frozen record. It is the + /// same file a freeze copies into `src/generated//`, and the exemplar's + /// first released suite is declared from it, so the pair below is a real + /// record checked against a real declaration. + /// @return paths The one-file record. + function recordOfTheGeneratedSnapshot() internal pure returns (string[] memory paths) { + paths = new string[](1); + paths[0] = LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"); + } + + /// A record declared by a released suite MUST pass, so the failing cases + /// below are discriminating rather than a check that cannot succeed. + function testFrozenSnapshotDeclaredPasses() external view { + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), releasedSuites()); + } + + /// A release in the record that the declaration does not name MUST fail, + /// naming the file. This is the hole the check exists for: nothing else + /// mentions that release, so without this it is simply never checked again + /// and every other assertion stays green. + function testFrozenSnapshotUndeclaredReverts() external { + vm.expectRevert( + abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0]) + ); + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), new DeploySuite[](0)); + } + + /// The match MUST be against what each suite's creation code derives, not + /// merely against a declaration existing. A repo that declares SOME + /// releases and misses one is the case that actually happens, and it is + /// indistinguishable from a full declaration to anything that only counts. + function testFrozenSnapshotDeclaredByAnotherSuiteReverts() external { + DeploySuite[] memory wrongRelease = new DeploySuite[](1); + wrongRelease[0] = releasedSuites()[1]; + assertEq(wrongRelease[0].suite, "second-address"); + + vm.expectRevert( + abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0]) + ); + this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), wrongRelease); + } + /// A consistent snapshot of the WRONG contract: every recorded field is /// `MockDeployable`'s and they all agree with each other, but it is /// presented as the candidate for a repo whose source is diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 2a08248..bc2df48 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -36,6 +36,125 @@ contract LibRainDeploySnapshotTest is Test { ); } + /// Where the record fixture is built. NOT `src/generated`: the inherited + /// record check reads that root, in other contracts, which forge runs in + /// parallel with this one — a fixture release there would be a release + /// those contracts have to fail on, for as long as it exists. + string constant FIXTURE_ROOT = "test/generated"; + + /// Writes one file into the fixture record. + /// + /// Carries a licence header because a run that fails midway leaves it + /// behind, and an unlicensed file in the tree is a second failure on top of + /// the first. + /// @param path The file to write, under `FIXTURE_ROOT`. + function writeFixture(string memory path) internal { + string[] memory components = vm.split(path, "/"); + string memory dir = components[0]; + for (uint256 i = 1; i < components.length - 1; i++) { + dir = string.concat(dir, "/", components[i]); + } + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(dir, true); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n"); + } + + /// Whether `paths` holds `path`. The walk's order is the filesystem's, so + /// membership is the only thing worth asserting about it. + /// @param paths The paths returned by the walk. + /// @param path The path to look for. + /// @return Whether it is there. + function holdsPath(string[] memory paths, string memory path) internal pure returns (bool) { + for (uint256 i = 0; i < paths.length; i++) { + if (keccak256(bytes(paths[i])) == keccak256(bytes(path))) { + return true; + } + } + return false; + } + + /// A release tag is what `tagForVersion` produces and nothing else, so + /// exactly the directories a freeze can write are the ones the record + /// counts. The rolling `candidate/` is excluded by that same rule rather + /// than by name. + function testIsTagAcceptsWhatAFreezeCanWrite() external pure { + assertTrue(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.tagForVersion("0.1.7"))); + assertTrue(LibRainDeploySnapshot.isTag("0_0_0")); + assertTrue(LibRainDeploySnapshot.isTag("10_20_30")); + + assertFalse(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.CANDIDATE)); + assertFalse(LibRainDeploySnapshot.isTag("0_1")); + assertFalse(LibRainDeploySnapshot.isTag("0_1_7-rc1")); + assertFalse(LibRainDeploySnapshot.isTag("0.1.7")); + assertFalse(LibRainDeploySnapshot.isTag("_1_7")); + assertFalse(LibRainDeploySnapshot.isTag("0_1_")); + assertFalse(LibRainDeploySnapshot.isTag("")); + assertFalse(LibRainDeploySnapshot.isTag("collision-guard")); + } + + /// EVERY version a freeze accepts MUST produce a directory the record + /// recognises as a release. A version that could be frozen to a directory + /// the record then ignores is exactly the orphan snapshot + /// `UnreleasableVersion` exists to prevent, and it is what two spellings of + /// the version rule would eventually produce. + function testEveryFreezableVersionIsATagTheRecordFinds(uint8 major, uint8 minor, uint8 patch) external pure { + string memory version = + string.concat(vm.toString(uint256(major)), ".", vm.toString(uint256(minor)), ".", vm.toString(uint256(patch))); + + assertTrue(LibRainDeploySnapshot.isStrictTriple(version, ".")); + assertTrue(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.tagForVersion(version))); + } + + /// The record is every file inside a release-tag directory, and only those. + /// + /// The whole point of reading the tree is that it is the one description of + /// what a repo has released that cannot fall behind, so a walk that quietly + /// found nothing would leave exactly the hole it is here to close. Driven + /// against a directory that really has releases in it. + function testFrozenSnapshotPathsFindsEveryReleaseAndNothingElse() external { + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_1/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_2/MockDeployableV2.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_2/Second.sol")); + // Not releases: the rolling snapshot, a version no freeze could have + // written, a file loose in the root, and a file too deep to be a record. + writeFixture(string.concat(FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_3-rc1/MockDeployable.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/Loose.sol")); + writeFixture(string.concat(FIXTURE_ROOT, "/0_0_1/nested/TooDeep.sol")); + + string[] memory paths = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FIXTURE_ROOT); + + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_1/MockDeployable.sol"))); + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/MockDeployableV2.sol"))); + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/Second.sol"))); + assertEq(paths.length, 3); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(FIXTURE_ROOT, true); + } + + /// A root that is not there at all MUST read as a repo that has released + /// nothing, not as a failure. That is the state of every deploy repo before + /// its first release, including this one. + function testFrozenSnapshotPathsOnAMissingRoot() external view { + assertFalse(vm.exists(FIXTURE_ROOT)); + assertEq(LibRainDeploySnapshot.frozenSnapshotPaths(vm, FIXTURE_ROOT).length, 0); + } + + /// The rolling snapshot MUST NOT be in this repo's own record. It is the + /// only directory in `src/generated/` today, and a walk that returned it + /// would make the candidate a release that has to be declared and deployed. + function testFrozenSnapshotPathsExcludesTheRollingSnapshot() external view { + assertTrue(vm.exists(LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry"))); + assertFalse( + holdsPath( + LibRainDeploySnapshot.frozenSnapshotPaths(vm, LibRainDeploySnapshot.LIB_FS_ROOT), + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, "AddressRegistry") + ) + ); + } + /// A strict `X.Y.Z` version MUST become its directory form. function testTagForVersionConvertsDots() external pure { assertEq(LibRainDeploySnapshot.tagForVersion("0.1.7"), "0_1_7"); From d8ebf9020f7c3f5d2fc9734c36ca9f3280f8c412 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 21:02:46 +0000 Subject: [PATCH 20/29] fix(ci): slither filter follows the rename, SPDX fixture stops declaring a license slither.config.json still filtered RainDeployVerifyOffline, a path that stopped existing when that contract became RainDeployVerifySnapshot. The renamed file was therefore never covered, which went unnoticed until it grew the cheatcode loops in checkFrozenSnapshotsReleased and calls-loop fired on them. The SPDX line the snapshot fixture writes is split, because `reuse lint` scans lines and cannot tell a license identifier inside a string literal from this file's own declaration. Co-Authored-By: Claude Opus 5 (1M context) --- slither.config.json | 2 +- test/src/lib/LibRainDeploySnapshot.t.sol | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/slither.config.json b/slither.config.json index ffcc6c9..f01fbea 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { - "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifyOffline)|AddressRegistryDeploySuites)\\.sol", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|AddressRegistryDeploySuites)\\.sol", "detectors_to_exclude": "assembly,low-level-calls" } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index bc2df48..7b1c994 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -57,7 +57,10 @@ contract LibRainDeploySnapshotTest is Test { //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dir, true); //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(path, "// SPDX-License-Identifier: LicenseRef-DCL-1.0\n"); + // Split so `reuse lint` reads this as a fixture rather than as this + // file's own license declaration -- an SPDX identifier in a string + // literal is indistinguishable from a real one to a line scanner. + vm.writeFile(path, string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n")); } /// Whether `paths` holds `path`. The walk's order is the filesystem's, so From f6c87dcdbec78bae76307ae464566ee1b4c08192 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 21:05:58 +0000 Subject: [PATCH 21/29] style: forge fmt, and the lint directive sits against the line it suppresses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment inserted between `//forge-lint: disable-next-line` and its target silently disarms it — the directive names the NEXT line and nothing warns when that stops being the line it was written for. Co-Authored-By: Claude Opus 5 (1M context) --- test/src/abstract/RainDeployVerifySnapshot.t.sol | 13 +++---------- test/src/lib/LibRainDeploySnapshot.t.sol | 7 ++++--- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index 4b6478e..769c57d 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -56,10 +56,7 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn /// works at the correct call depth. /// @param paths The frozen record's files. /// @param released The declared released suites. - function externalCheckFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) - external - view - { + function externalCheckFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) external view { checkFrozenSnapshotsReleased(paths, released); } @@ -84,9 +81,7 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn /// mentions that release, so without this it is simply never checked again /// and every other assertion stays green. function testFrozenSnapshotUndeclaredReverts() external { - vm.expectRevert( - abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0]) - ); + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0])); this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), new DeploySuite[](0)); } @@ -99,9 +94,7 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn wrongRelease[0] = releasedSuites()[1]; assertEq(wrongRelease[0].suite, "second-address"); - vm.expectRevert( - abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0]) - ); + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotNotReleased.selector, recordOfTheGeneratedSnapshot()[0])); this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), wrongRelease); } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 7b1c994..ab3fa5c 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -56,10 +56,10 @@ contract LibRainDeploySnapshotTest is Test { } //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dir, true); - //forge-lint: disable-next-line(unsafe-cheatcode) // Split so `reuse lint` reads this as a fixture rather than as this // file's own license declaration -- an SPDX identifier in a string // literal is indistinguishable from a real one to a line scanner. + //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile(path, string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n")); } @@ -102,8 +102,9 @@ contract LibRainDeploySnapshotTest is Test { /// `UnreleasableVersion` exists to prevent, and it is what two spellings of /// the version rule would eventually produce. function testEveryFreezableVersionIsATagTheRecordFinds(uint8 major, uint8 minor, uint8 patch) external pure { - string memory version = - string.concat(vm.toString(uint256(major)), ".", vm.toString(uint256(minor)), ".", vm.toString(uint256(patch))); + string memory version = string.concat( + vm.toString(uint256(major)), ".", vm.toString(uint256(minor)), ".", vm.toString(uint256(patch)) + ); assertTrue(LibRainDeploySnapshot.isStrictTriple(version, ".")); assertTrue(LibRainDeploySnapshot.isTag(LibRainDeploySnapshot.tagForVersion(version))); From 0c5967d91bbdc07ced0f38c0b306a6ed8b6f6c38 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 21:38:07 +0000 Subject: [PATCH 22/29] fix(snapshot): a failed freeze is retryable, and the writer has one root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three filesystem hazards in `LibRainDeploySnapshot`, all of which end with a release nobody can cut. `freeze` created `src/generated//` before the per-contract `NothingToFreeze` checks. Filesystem cheatcodes are not undone by a revert, so a throw partway left a partial record behind — and a partial record is a frozen tag, which `SnapshotAlreadyFrozen` then refuses the retry of. The only exit was deleting a directory this design calls append-only, so the failure wedged the release rather than merely stopping it. Every byte that will be written is now read before the directory is created; what follows the `createDir` is writes only. An empty `contractNames` was the same wedge by a shorter route: nothing to write, `/` created, success reported, and the real cut of that tag refused forever. `EmptyRelease` refuses it. `writeSnapshot`'s `outputRoot` had no production caller — `script/Build.sol` passed `LIB_FS_ROOT` and the only other caller was the test of its own guard. It reached a non-default root by staging through `src/generated/` and recursively removing it afterwards, under the directory real frozen releases live in. The capability goes, and with it the `vm.removeDir` and the `SnapshotScratchDirCollision` that guarded it. A test that wants a record tree of its own writes one and reads it with `frozenSnapshotPaths`, which does take a root — reading somebody else's tree is a thing a walk genuinely does; writing this repo's record somewhere else is not. `testWriteSnapshotWritesTheSnapshotAtItsPath` replaces the two guard tests: the snapshot lands where the library says, over a directory that is already there, which is the ordinary case the removed guard existed to distinguish from staging. Co-Authored-By: Claude Opus 5 (1M context) --- foundry.toml | 6 +- script/Build.sol | 6 +- src/lib/LibRainDeploySnapshot.sol | 108 ++++++++++------------- test/src/lib/LibRainDeploySnapshot.t.sol | 93 ++++++++++++------- 4 files changed, 111 insertions(+), 102 deletions(-) diff --git a/foundry.toml b/foundry.toml index 8302693..d2ba1a6 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,8 +36,10 @@ ast = true fs_permissions = [ { access = "read", path = "./foundry.toml" }, { access = "read-write", path = "./src" }, - # script/BuildTestSnapshots.sol emits the mock snapshots the verification - # abstracts are exercised against. + # LibRainDeploySnapshotTest builds a record tree of its own under + # test/generated to drive the frozen-record walk. NOT src/generated: the + # inherited record check reads that root from contracts forge runs in + # parallel, so a fixture release there would be one they have to fail on. { access = "read-write", path = "./test" }, # GeneratedSnapshotShapeTest reads the compiler's AST out of the artifact. { access = "read", path = "./out" }, diff --git a/script/Build.sol b/script/Build.sol index a59416d..9d66aeb 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -61,11 +61,7 @@ contract Build is Script { /// this repo currently compiles. function regenerateCandidate() internal { LibRainDeploySnapshot.writeSnapshot( - vm, - LibRainDeploySnapshot.LIB_FS_ROOT, - LibRainDeploySnapshot.CANDIDATE, - "AddressRegistry", - type(AddressRegistry).creationCode + vm, LibRainDeploySnapshot.CANDIDATE, "AddressRegistry", type(AddressRegistry).creationCode ); } } diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 2cc5d0a..d6a9912 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -27,13 +27,12 @@ error NothingToFreeze(string path); /// @param dir The frozen directory that already exists. error SnapshotAlreadyFrozen(string tag, string dir); -/// Thrown when generating to a non-default output root would have to stage -/// through a `src/generated/` directory that already exists. Staging removes -/// that directory afterwards, so proceeding would recursively delete content -/// this call did not create — a real frozen release, if the name collided. -/// @param dir The colliding directory. -/// @param path The staging path under `src/generated/`. -error SnapshotScratchDirCollision(string dir, string path); +/// Thrown when a freeze names no contracts. There would be nothing to write, so +/// it would leave `/` empty and report success — and an empty `/` is +/// still a frozen tag, which `SnapshotAlreadyFrozen` then refuses the real cut +/// of forever. A release lost to a directory with nothing in it. +/// @param tag The release tag that was being cut. +error EmptyRelease(string tag); /// @title LibRainDeploySnapshot /// @notice Which release is being built, where its record lives, and how it is @@ -250,48 +249,24 @@ library LibRainDeploySnapshot { } } - /// Generate one snapshot for one contract, under an arbitrary output root. + /// Generate one snapshot for one contract. /// - /// The root is a parameter because a repo generates real deploy records - /// under `src/generated/` and test records under `test/generated/`, and - /// both must come from THIS code path — a second emitter would make the - /// shape assertions a statement about the wrong generator. - /// - /// `LibFs` hardcodes `src/generated/` and takes a contract name rather than - /// a path, so a non-default root is reached by STAGING there and moving the - /// result, then removing the staging directory. That recursive removal is - /// the sharp edge: it is under `src/generated/`, where real frozen releases - /// live. It is guarded by refusing to stage through a directory that - /// already exists, so a name collision fails loudly instead of deleting a - /// release. - /// - /// The guard exists because the clean fix is upstream and not ours: - /// `LibFs.pathForContract` needs to take an output root - /// (`pathForContract(string root, string contractName)`), with - /// `buildFileForContract` passing it through. Then a caller writes directly - /// to `test/generated/` and there is no staging, no copy and no removal at - /// all. + /// There is no output root to choose. `LibFs.pathForContract` hardcodes + /// `LIB_FS_ROOT` and takes a contract name rather than a path, and this is + /// the repo's real deploy record, which belongs under that root and nowhere + /// else. A test that wants a record tree of its own writes one with + /// `vm.writeFile` and reads it with `frozenSnapshotPaths`, which does take a + /// root, because reading somebody else's tree is a thing a walk genuinely + /// does and writing this repo's record somewhere else is not. /// @param vm The Vm instance for file operations. - /// @param outputRoot Where the snapshot should end up. /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. /// @param contractName The contract the snapshot describes. /// @param creationCode That contract's creation code. /// @return The path written. - function writeSnapshot( - Vm vm, - string memory outputRoot, - string memory dir, - string memory contractName, - bytes memory creationCode - ) internal returns (string memory) { - bool staging = keccak256(bytes(outputRoot)) != keccak256(bytes(LIB_FS_ROOT)); - // Staging ends by removing the directory, so it must not begin with one - // that already exists. A test snapshot whose `dir` collided with a real - // frozen tag would otherwise destroy it. - if (staging && vm.exists(dirForSnapshot(dir))) { - revert SnapshotScratchDirCollision(dir, dirForSnapshot(dir)); - } - + function writeSnapshot(Vm vm, string memory dir, string memory contractName, bytes memory creationCode) + internal + returns (string memory) + { LibRainDeploy.etchZoltuFactory(vm); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(dirForSnapshot(dir), true); @@ -318,20 +293,7 @@ library LibRainDeploySnapshot { ) ); - string memory written = pathForSnapshot(dir, contractName); - if (!staging) { - return written; - } - - string memory destDir = string.concat(outputRoot, "/", dir); - string memory dest = string.concat(destDir, "/", contractName, ".sol"); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(destDir, true); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(dest, vm.readFile(written)); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.removeDir(dirForSnapshot(dir), true); - return dest; + return pathForSnapshot(dir, contractName); } /// The import block of a generated alias lib. @@ -436,11 +398,23 @@ library LibRainDeploySnapshot { /// Regenerate the rolling snapshot and freeze it as this release's record, /// in that order, in one call. /// - /// Every guard runs before anything is written: + /// Every guard runs, and every byte that will be written is in hand, BEFORE + /// `/` is created. That ordering is load bearing rather than tidy. + /// Filesystem cheatcodes are not undone by a revert, so a throw once the + /// directory exists leaves a partial record behind — and a partial record is + /// a frozen tag, which `SnapshotAlreadyFrozen` then refuses the retry of. + /// The only exit from that state is deleting a directory this design calls + /// append-only, so the release is wedged by the failure rather than merely + /// stopped by it. + /// + /// The guards, in order: /// /// - the version must be strict `X.Y.Z` (`deployTag`) /// - this release must not already be frozen — a release is cut once - /// - there must be something to freeze after regenerating + /// - the release must name at least one contract, because a release with no + /// record is not a release and freezing one wedges the tag exactly as a + /// partial write does + /// - every named contract must have a rolling snapshot, once regenerated /// /// The frozen copy is the bytes just regenerated, read back from disk, so /// "the record matches the candidate" is true by construction rather than @@ -455,18 +429,28 @@ library LibRainDeploySnapshot { if (vm.exists(frozenDir)) { revert SnapshotAlreadyFrozen(tag, frozenDir); } + if (contractNames.length == 0) { + revert EmptyRelease(tag); + } regenerate(); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(frozenDir, true); + // Read the whole record before writing any of it. Every reason this + // call can fail is now behind it, so what follows is writes only. + string[] memory records = new string[](contractNames.length); for (uint256 i = 0; i < contractNames.length; i++) { string memory rollingPath = pathForSnapshot(CANDIDATE, contractNames[i]); if (!vm.exists(rollingPath)) { revert NothingToFreeze(rollingPath); } + records[i] = vm.readFile(rollingPath); + } + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(frozenDir, true); + for (uint256 i = 0; i < contractNames.length; i++) { //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(pathForSnapshot(tag, contractNames[i]), vm.readFile(rollingPath)); + vm.writeFile(pathForSnapshot(tag, contractNames[i]), records[i]); } } } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index ab3fa5c..6a3dff8 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -5,8 +5,9 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import { + EmptyRelease, LibRainDeploySnapshot, - SnapshotScratchDirCollision, + NothingToFreeze, UnreleasableVersion } from "../../../src/lib/LibRainDeploySnapshot.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; @@ -25,15 +26,16 @@ contract LibRainDeploySnapshotTest is Test { return LibRainDeploySnapshot.tagForVersion(version); } + /// A regeneration that does nothing, for driving `freeze`'s guards. Every + /// one of them either fires before this runs or is about what it left + /// behind, so a no-op is what makes "the guard fired" and "the guard fired + /// FIRST" the same observation. + function noRegeneration() internal {} + /// External wrapper so `vm.expectRevert` lands at the right call depth. - /// @param outputRoot Where the snapshot should end up. - /// @param dir The snapshot directory. - /// @return The path written. - function externalWriteSnapshot(string memory outputRoot, string memory dir) external returns (string memory) { - return - LibRainDeploySnapshot.writeSnapshot( - vm, outputRoot, dir, "MockDeployable", type(MockDeployable).creationCode - ); + /// @param contractNames The contracts to freeze. + function externalFreeze(string[] memory contractNames) external { + LibRainDeploySnapshot.freeze(vm, noRegeneration, contractNames); } /// Where the record fixture is built. NOT `src/generated`: the inherited @@ -192,40 +194,65 @@ contract LibRainDeploySnapshotTest is Test { assertEq(LibRainDeploySnapshot.pathForSnapshot("0_1_7", "Foo"), "src/generated/0_1_7/Foo.sol"); } - /// Generating to a non-default root stages through `src/generated/` - /// and REMOVES it afterwards. So it MUST refuse a directory that already - /// exists: that removal is under the directory frozen releases live in, and - /// a colliding `dir` would destroy one. - function testWriteSnapshotRefusesAnExistingScratchDir() external { - string memory dir = "collision-guard"; + /// A snapshot MUST land at the path this library says it does, and writing + /// one over a directory that is already there is the ORDINARY case: the + /// rolling snapshot is regenerated into the same `candidate/` on every + /// build. + /// + /// Written into a directory that is not tag shaped on purpose. The record + /// root is the real `src/generated/`, which the inherited record check + /// walks from other contracts that forge runs in parallel with this one, so + /// a tag-shaped name here would be a release those contracts have to fail + /// on for as long as it exists. + function testWriteSnapshotWritesTheSnapshotAtItsPath() external { + string memory dir = "write-snapshot-not-a-tag"; + assertFalse(LibRainDeploySnapshot.isTag(dir)); //forge-lint: disable-next-line(unsafe-cheatcode) vm.createDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); - vm.expectRevert( - abi.encodeWithSelector(SnapshotScratchDirCollision.selector, dir, LibRainDeploySnapshot.dirForSnapshot(dir)) - ); - this.externalWriteSnapshot("test/generated", dir); + string memory written = + LibRainDeploySnapshot.writeSnapshot(vm, dir, "MockDeployable", type(MockDeployable).creationCode); + + assertEq(written, LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable")); + assertTrue(vm.exists(written)); - // The guard must leave it alone, not remove it. - assertTrue(vm.exists(LibRainDeploySnapshot.dirForSnapshot(dir))); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); } - /// Generating to the DEFAULT root does not stage, so it MUST NOT refuse an - /// existing directory — that is the ordinary case of regenerating a - /// snapshot that is already there. - function testWriteSnapshotAllowsAnExistingDirWithoutStaging() external { - string memory dir = "no-staging-guard"; - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + /// A freeze that names no contracts MUST be refused. It would write + /// nothing, report success, and leave `/` there — and an empty + /// `/` is a frozen tag, so the real cut of that release could never + /// happen afterwards. + function testFreezeRefusesAnEmptyRelease() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); - assertEq( - this.externalWriteSnapshot(LibRainDeploySnapshot.LIB_FS_ROOT, dir), - LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable") + vm.expectRevert(abi.encodeWithSelector(EmptyRelease.selector, tag)); + this.externalFreeze(new string[](0)); + + assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); + } + + /// A freeze that throws MUST leave NOTHING behind. Filesystem cheatcodes + /// survive a revert, so a `/` created before the last thing that can + /// fail is a partial record that outlives the failure — and it is a frozen + /// tag, which the immutability check then refuses the retry of, forever. + /// The exit from that state is deleting a directory this design calls + /// append-only, so the ordering here is what keeps a failed release + /// retryable at all. + function testFreezeLeavesNothingBehindWhenThereIsNothingToFreeze() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string[] memory contractNames = new string[](1); + contractNames[0] = "NoSuchContract"; + + vm.expectRevert( + abi.encodeWithSelector( + NothingToFreeze.selector, + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, contractNames[0]) + ) ); + this.externalFreeze(contractNames); - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); } } From 0b7db36504ffd5d623d728a11ecb16e2306b4b61 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 21:38:21 +0000 Subject: [PATCH 23/29] fix(verify): the record is matched on what it declares, not on its text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `checkFrozenSnapshotsReleased` claimed to match a record against the address that record's file derives, but asked whether a released suite's address occurred anywhere in the file's TEXT — `CREATION_CODE` and `RUNTIME_CODE` included. A record is mostly two hex payloads thousands of digits long, so that is a question about characters rather than about what was deployed, and it answers yes for a release the record never declared. `recordedDeployedAddress` reads the `DEPLOYED_ADDRESS` declaration itself: the whole declaration is matched, so it cannot be satisfied by characters inside a payload, and the value is that line's last token with the type wrapper and the terminator stripped. `LibCodeGen` emits an address constant on one line — the declaration occupies 88 characters and wrapping needs 120 — so the declaration is a line. `address(0x...);` and a bare `0x...;` read the same, so which wrapper the generator chose is not something this has to know. Read from the text rather than from the AST that `GeneratedSnapshotShapeTest` pins the shape against, because a record is reached by its PATH, which is what the walk returns, while its artifact path is not something a caller can name — foundry disambiguates those by whatever else happens to share the basename. A file in the record with no `DEPLOYED_ADDRESS` at all is `FrozenSnapshotUnreadable` rather than `FrozenSnapshotNotReleased`. The second says a declaration is missing an entry, which would send the reader to `releasedSuites()` to add one for something that is not a snapshot. Co-Authored-By: Claude Opus 5 (1M context) --- src/abstract/RainDeployVerifySnapshot.sol | 64 ++++++++++++++++-- .../abstract/RainDeployVerifySnapshot.t.sol | 66 +++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index c1e9783..c2c7499 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -45,6 +45,16 @@ error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, byte /// @param path The frozen record file no released suite declares. error FrozenSnapshotNotReleased(string path); +/// Thrown when a file in the frozen record declares no deployed address. The +/// record holds generated snapshots and nothing else, and `DEPLOYED_ADDRESS` is +/// what makes one the record of a deployment rather than a file that happens to +/// be in a release directory. Distinct from `FrozenSnapshotNotReleased`, which +/// is a declaration that is missing something — this is a record that cannot be +/// read at all, and reporting it as undeclared would send the reader after the +/// wrong thing. +/// @param path The record file with no `DEPLOYED_ADDRESS` declaration. +error FrozenSnapshotUnreadable(string path); + /// @title RainDeployVerifySnapshot /// @notice Every deploy-pin assertion that needs no network, for every suite /// a repo declares. Three groups, which catch different things and are @@ -103,6 +113,42 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { } } + /// @dev The declaration a generated snapshot records its deploy address in. + /// Matched whole, so what is being looked for is the DECLARATION and cannot + /// be satisfied by characters that happen to occur inside a hex payload. + string constant DEPLOYED_ADDRESS_DECLARATION = "address constant DEPLOYED_ADDRESS ="; + + /// The address a frozen record declares as its deploy address. + /// + /// `LibCodeGen` emits an address constant on ONE line — wrapping needs 120 + /// characters and this declaration occupies 88 — so the declaration is a + /// line, and its value is that line's last token with the type wrapper and + /// the terminator stripped. `address(0x...);` and a bare `0x...;` read the + /// same, so which wrapper the generator chose is not something this has to + /// know. + /// + /// That every generated snapshot HAS this declaration, second, of type + /// `address`, is pinned by `GeneratedSnapshotShapeTest` against the + /// compiler's own AST. Read from the text here rather than from that AST + /// because a record is reached by its PATH, which is what the walk returns, + /// while its artifact path is not something a caller can name — foundry + /// disambiguates those by whatever else happens to share the basename. + /// @param path The record file, for the error only. + /// @param record The record file's contents. + /// @return The address the record declares. + function recordedDeployedAddress(string memory path, string memory record) internal pure returns (address) { + string[] memory lines = vm.split(record, "\n"); + for (uint256 i = 0; i < lines.length; i++) { + if (!vm.contains(lines[i], DEPLOYED_ADDRESS_DECLARATION)) { + continue; + } + string[] memory tokens = vm.split(lines[i], " "); + string memory literal = tokens[tokens.length - 1]; + return vm.parseAddress(vm.replace(vm.replace(vm.replace(literal, "address(", ""), ")", ""), ";", "")); + } + revert FrozenSnapshotUnreadable(path); + } + /// Checks the frozen record against the released declaration: every file in /// the record is declared by a released suite. /// @@ -119,19 +165,25 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { /// would let the candidate declare a frozen release — and the candidate is /// exactly what the chain group does not check. /// - /// The match is by derived address, which is a pure function of the - /// creation code and is what the record records. A suite whose creation - /// code derives the address in a file IS that file's release. Nothing is - /// matched by name, which would assert only that a convention was followed. + /// The match is by address: the address a file DECLARES against the address + /// a suite's creation code DERIVES. The derived side is a pure function of + /// the creation code, so a suite whose creation code derives the address a + /// file records IS that file's release. + /// + /// Nothing is matched by name, which would assert only that a convention + /// was followed. Nothing is matched by searching the file's text either: a + /// record is mostly two hex payloads thousands of digits long, and an + /// address that merely OCCURS somewhere in one of them says nothing about + /// what the file records. /// @param paths The frozen record's files. /// @param released The declared released suites. function checkFrozenSnapshotsReleased(string[] memory paths, DeploySuite[] memory released) internal view { for (uint256 i = 0; i < paths.length; i++) { - string memory record = vm.readFile(paths[i]); + address recorded = recordedDeployedAddress(paths[i], vm.readFile(paths[i])); bool declared = false; for (uint256 j = 0; j < released.length; j++) { - if (vm.contains(record, vm.toString(LibRainDeploy.zoltuAddress(released[j].creationCode)))) { + if (recorded == LibRainDeploy.zoltuAddress(released[j].creationCode)) { declared = true; break; } diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index 769c57d..59885a7 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -7,6 +7,7 @@ import {DeployCandidate, DeploySuite} from "../../../src/abstract/RainDeploySuit import { CandidateSourceMismatch, FrozenSnapshotNotReleased, + FrozenSnapshotUnreadable, RainDeployVerifySnapshot, StoredAddressMismatch, StoredCodeHashMismatch, @@ -60,6 +61,15 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn checkFrozenSnapshotsReleased(paths, released); } + /// External wrapper for `recordedDeployedAddress` so `vm.expectRevert` + /// works at the correct call depth. + /// @param path The record file, for the error only. + /// @param record The record file's contents. + /// @return The address the record declares. + function externalRecordedDeployedAddress(string memory path, string memory record) external pure returns (address) { + return recordedDeployedAddress(path, record); + } + /// The real generated snapshot, standing in for a frozen record. It is the /// same file a freeze copies into `src/generated//`, and the exemplar's /// first released suite is declared from it, so the pair below is a real @@ -98,6 +108,62 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn this.externalCheckFrozenSnapshotsReleased(recordOfTheGeneratedSnapshot(), wrongRelease); } + /// A record in the generated shape that DECLARES one address and merely + /// mentions another — in a comment, and in a second address constant. + /// @param declared The address the record declares as its deploy address. + /// @param mentioned The address the record only mentions. + /// @return The record's contents. + function recordDeclaring(address declared, address mentioned) internal pure returns (string memory) { + return string.concat( + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + "/// @dev The deterministic deploy address of the contract, which is not\n/// ", + vm.toString(mentioned), + ".\n", + "address constant DEPLOYED_ADDRESS = address(", + vm.toString(declared), + ");\n\n", + "/// @dev Some other address this record carries.\n", + "address constant OTHER_ADDRESS = address(", + vm.toString(mentioned), + ");\n" + ); + } + + /// The address a record is matched on MUST be the one it DECLARES, never + /// one that merely appears in its text. A record is two hex payloads + /// thousands of digits long plus a comment or two, so "this address occurs + /// somewhere in the file" is a question about characters rather than about + /// what was deployed — and it answers yes for a release the declaration + /// never named. + /// + /// Driven at the read rather than through a record on disk: a record whose + /// text carries a released address it does not declare cannot be built out + /// of this repo's real snapshots without grinding creation code for one, + /// and a `.sol` fixture left behind by a failed run is a file the next + /// build has to compile. + function testFrozenSnapshotMatchesTheDeclarationNotTheText() external view { + address released = LibRainDeploy.zoltuAddress(releasedSuites()[0].creationCode); + assertEq(released, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string memory record = recordDeclaring(address(0xdead), released); + // The released address really is in there. + assertTrue(vm.contains(record, vm.toString(released))); + + assertEq(this.externalRecordedDeployedAddress("record.sol", record), address(0xdead)); + } + + /// A file in the record that declares no deploy address at all MUST fail as + /// unreadable, naming itself. Reporting it as undeclared would send the + /// reader to `releasedSuites()` to add an entry for something that is not a + /// snapshot. + function testFrozenSnapshotWithoutADeployedAddressReverts() external { + string[] memory paths = new string[](1); + paths[0] = "src/concrete/AddressRegistry.sol"; + + vm.expectRevert(abi.encodeWithSelector(FrozenSnapshotUnreadable.selector, paths[0])); + this.externalCheckFrozenSnapshotsReleased(paths, releasedSuites()); + } + /// A consistent snapshot of the WRONG contract: every recorded field is /// `MockDeployable`'s and they all agree with each other, but it is /// presented as the candidate for a repo whose source is From e37f499f3156f16711e194f7fac3a3ecfaaf8714 Mon Sep 17 00:00:00 2001 From: David Meister Date: Thu, 13 Aug 2026 21:38:32 +0000 Subject: [PATCH 24/29] test(chain): the candidate scope gets its own file `rainix-sol-single-contract` refuses a file that declares two contracts, and `RainDeployVerifyChain.t.sol` declared `RainDeployVerifyChainTest` and `RainDeployVerifyChainCandidateTest`. They are two contracts rather than two tests because the suites a contract inherits are the whole of what the matrix runs over: a contract has exactly one `releasedSuites()`/`candidateSuite()` declaration, so a second scope is a second contract. One contract per file is then just the convention applied to what this already was. Co-Authored-By: Claude Opus 5 (1M context) --- test/src/abstract/RainDeployVerifyChain.t.sol | 79 +--------------- .../RainDeployVerifyChainCandidate.t.sol | 92 +++++++++++++++++++ 2 files changed, 94 insertions(+), 77 deletions(-) create mode 100644 test/src/abstract/RainDeployVerifyChainCandidate.t.sol diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index c3ed7a2..b6204f3 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -2,7 +2,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../../src/abstract/RainDeploySuitesBase.sol"; import {DerivedDeploy} from "../../../src/abstract/RainDeployVerifyBase.sol"; import { CodeHashMismatchOnNetwork, @@ -14,7 +13,6 @@ import {ExampleDeploySuites} from "../../abstract/ExampleDeploySuites.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import { BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, - CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE } from "../../../src/generated/candidate/AddressRegistry.sol"; @@ -25,8 +23,8 @@ import { /// `testSuitesLiveOnEverySupportedNetwork` is the passing case: it forks /// every network `supportedNetworks()` returns and finds both released suites. /// The candidate is etched too, so nothing here depends on whether the matrix -/// happens to reach it — `RainDeployVerifyChainCandidateTest` below is what -/// says it does not. +/// happens to reach it — `RainDeployVerifyChainCandidateTest`, in +/// `RainDeployVerifyChainCandidate.t.sol`, is what says it does not. /// /// `setUp` places the code with a persistent `vm.etch` rather than pointing the /// exemplar at some real deployment in another repo. A real one would make this @@ -239,76 +237,3 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain } } } - -/// @title RainDeployVerifyChainCandidateTest -/// @notice A repo between releases: source has moved on, so the candidate is a -/// different contract from the last release and is deployed nowhere. The -/// inherited matrix MUST pass anyway. -/// -/// This is the ordinary state of a deploy repo, not a fault in one. A candidate -/// is what the NEXT release will be; requiring it to already be on chain asks -/// the repo to have deployed something it has not released, and would make -/// every repo permanently red for as long as its source was ahead of its last -/// deploy — which is most of the time. -/// -/// It needs its own declaration because `ExampleDeploySuites` cannot say it: -/// its candidate shares creation code with a release, so every address it names -/// is live whichever scope the matrix uses, and the two are indistinguishable -/// there. Here the released suite is made live and the candidate deliberately -/// is not, at a DIFFERENT address, which is the only configuration that can -/// tell them apart. -contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { - /// @inheritdoc RainDeploySuitesBase - function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { - suites = new DeploySuite[](1); - suites[0] = DeploySuite({ - suite: "address-registry-0-0-1", - creationCode: ADDRESS_REGISTRY_CREATION_CODE, - storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, - storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, - storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, - artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", - dependencies: new address[](0) - }); - } - - /// @inheritdoc RainDeploySuitesBase - function candidateSuite() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ - snapshot: DeploySuite({ - suite: "second-address-candidate", - creationCode: type(MockDeployableV2).creationCode, - storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), - storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), - storedRuntimeCode: type(MockDeployableV2).runtimeCode, - artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", - dependencies: new address[](0) - }), - sourceCreationCode: type(MockDeployableV2).creationCode - }); - } - - /// The RELEASE is live everywhere. The candidate is not touched. - function setUp() external { - vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); - vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); - } - - /// The matrix MUST pass with the candidate on no network at all, and the - /// candidate MUST really be absent — otherwise this passes for the wrong - /// reason and says nothing about the scope. - function testChainIgnoresAnUndeployedCandidate() external { - address candidateAddress = LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); - assertNotEq(candidateAddress, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); - - string[] memory networks = LibRainDeploy.supportedNetworks(); - for (uint256 i = 0; i < networks.length; i++) { - uint256 forkId = vm.createSelectFork(networks[i]); - (forkId); - assertEq(candidateAddress.code.length, 0); - assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); - } - - this.testSuitesLiveOnEverySupportedNetwork(); - } -} diff --git a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol new file mode 100644 index 0000000..8fa85ac --- /dev/null +++ b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../../src/abstract/RainDeploySuitesBase.sol"; +import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; +import { + BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, + CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE, + DEPLOYED_ADDRESS as ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE +} from "../../../src/generated/candidate/AddressRegistry.sol"; + +/// @title RainDeployVerifyChainCandidateTest +/// @notice A repo between releases: source has moved on, so the candidate is a +/// different contract from the last release and is deployed nowhere. The +/// inherited matrix MUST pass anyway. +/// +/// This is the ordinary state of a deploy repo, not a fault in one. A candidate +/// is what the NEXT release will be; requiring it to already be on chain asks +/// the repo to have deployed something it has not released, and would make +/// every repo permanently red for as long as its source was ahead of its last +/// deploy — which is most of the time. +/// +/// It needs its own declaration because `ExampleDeploySuites` cannot say it: +/// its candidate shares creation code with a release, so every address it names +/// is live whichever scope the matrix uses, and the two are indistinguishable +/// there. Here the released suite is made live and the candidate deliberately +/// is not, at a DIFFERENT address, which is the only configuration that can +/// tell them apart. +/// +/// It is its own contract in its own file rather than a second declaration +/// beside `RainDeployVerifyChainTest`, because the suites a contract inherits +/// are the whole of what the matrix runs over: a contract has exactly one +/// declaration, so a second scope is a second contract. +contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](1); + suites[0] = DeploySuite({ + suite: "address-registry-0-0-1", + creationCode: ADDRESS_REGISTRY_CREATION_CODE, + storedDeployedAddress: ADDRESS_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: ADDRESS_REGISTRY_BYTECODE_HASH, + storedRuntimeCode: ADDRESS_REGISTRY_RUNTIME_CODE, + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuite() internal pure override returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "second-address-candidate", + creationCode: type(MockDeployableV2).creationCode, + storedDeployedAddress: LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode), + storedBytecodeHash: keccak256(type(MockDeployableV2).runtimeCode), + storedRuntimeCode: type(MockDeployableV2).runtimeCode, + artifactPath: "test/concrete/MockDeployableV2.sol:MockDeployableV2", + dependencies: new address[](0) + }), + sourceCreationCode: type(MockDeployableV2).creationCode + }); + } + + /// The RELEASE is live everywhere. The candidate is not touched. + function setUp() external { + vm.etch(ADDRESS_REGISTRY_DEPLOYED_ADDRESS, ADDRESS_REGISTRY_RUNTIME_CODE); + vm.makePersistent(ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + } + + /// The matrix MUST pass with the candidate on no network at all, and the + /// candidate MUST really be absent — otherwise this passes for the wrong + /// reason and says nothing about the scope. + function testChainIgnoresAnUndeployedCandidate() external { + address candidateAddress = LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode); + assertNotEq(candidateAddress, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string[] memory networks = LibRainDeploy.supportedNetworks(); + for (uint256 i = 0; i < networks.length; i++) { + uint256 forkId = vm.createSelectFork(networks[i]); + (forkId); + assertEq(candidateAddress.code.length, 0); + assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); + } + + this.testSuitesLiveOnEverySupportedNetwork(); + } +} From 95a41bfe1a040fcea3ac097c5092bb7da57f4ea4 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 07:12:01 +0000 Subject: [PATCH 25/29] feat(release): the record and the declaration of it come from one call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `package-release.yaml` ran `run()`, which only rewrites the rolling snapshot, so a `sol-v*` tag published to Soldeer and froze nothing. Naming `cutRelease()` on its own reds main instead: the freeze writes `src/generated//` and `testEveryFrozenSnapshotIsReleased` then fails, because nothing generates the released declaration to match it. `LibRainDeploySnapshot.writeReleasedSuitesLib` emits `src/lib/LibReleased.sol` from the frozen record: one entry per record file, in tag order, whose address, code hash, creation code and runtime code alias that release's own immutable snapshot. The key, the artifact path and the dependencies come from the candidate declaration, which is why `Build` inherits `AddressRegistryDeploySuites` rather than restating them. Both entry points write it — `run()` must, or the lib an ordinary build imports would not exist before the first release — and `cutRelease()` writes it after the freeze, so the release being cut is in it. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/package-release.yaml | 17 +- script/Build.sol | 38 ++- src/abstract/AddressRegistryDeploySuites.sol | 20 +- src/lib/LibAddressRegistryReleased.sol | 28 ++ src/lib/LibRainDeploySnapshot.sol | 270 +++++++++++++++ test/src/lib/LibRainDeploySnapshot.t.sol | 336 +++++++++++++++++++ 6 files changed, 688 insertions(+), 21 deletions(-) create mode 100644 src/lib/LibAddressRegistryReleased.sol diff --git a/.github/workflows/package-release.yaml b/.github/workflows/package-release.yaml index f1c9fe1..6acb357 100644 --- a/.github/workflows/package-release.yaml +++ b/.github/workflows/package-release.yaml @@ -6,10 +6,17 @@ name: Package Release # wrong for: autopublish bumps [package].version on every merge while the frozen # deploy tag only advances at deploy time. # -# The tag names the version; rainix-tag-release regenerates the snapshot for it, -# verifies the live chains match the fresh pins, publishes rain-deploy to -# Soldeer, and commits the frozen snapshot back to main. The on-chain deploy is -# separate and manual, run before tagging; this never broadcasts. +# The tag names the version; rainix-tag-release runs `cutRelease()`, which +# freezes src/generated// AND regenerates the released-suites lib that +# declares it in one call, then verifies the live chains match the fresh pins, +# publishes rain-deploy to Soldeer, and commits both back to main. One call, +# because a frozen record no declaration names is a release every check silently +# stops asking about. +# +# The on-chain deploy is separate and manual, run BEFORE tagging; this never +# broadcasts. The chain verification inside rainix-tag-release is what fails if a +# newly declared release is not on chain, and that is the intended gate: a +# release is declared here only once it is a deployment that already happened. # # Switching lifecycles retracts nothing: every version already published stays # published, and consumers pin exact versions, so this changes who cuts a @@ -23,5 +30,5 @@ jobs: uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main with: soldeer-package: rain-deploy - snapshot-generate-cmd: forge script ./script/Build.sol && forge fmt + snapshot-generate-cmd: forge script ./script/Build.sol --sig "cutRelease()" && forge fmt secrets: inherit diff --git a/script/Build.sol b/script/Build.sol index 9d66aeb..4f309fb 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; @@ -25,36 +26,53 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// record — what each release actually deployed — which is what /// `AddressRegistryDeploySuites.releasedSuites()` enumerates. /// -/// The tag, both snapshot paths, the freeze, the snapshot writer and the alias -/// lib writer all come from `LibRainDeploySnapshot`, which in turn emits every -/// constant through `LibCodeGen` and writes snapshots through `LibFs`. This -/// script is the declaration and the sequencing, nothing else. -contract Build is Script { +/// BOTH entry points also regenerate that released-suites lib from the record. +/// `run()` must: the lib is imported by ordinary source, so a repo before its +/// first release still has to have one, and with nothing frozen it declares an +/// empty set. +/// +/// The metadata each released entry carries beyond its frozen snapshot comes +/// from `candidateSuite()`, which is why this inherits the declaration rather +/// than restating it. There is one suite key, one artifact path and one +/// dependency list in this repo, and a second copy of them here is a second +/// copy that drifts. +/// +/// The tag, both snapshot paths, the freeze, the snapshot writer and both +/// generated-lib writers all come from `LibRainDeploySnapshot`, which in turn +/// emits every constant through `LibCodeGen` and writes snapshots through +/// `LibFs`. This script is the declaration and the sequencing, nothing else. +contract Build is Script, AddressRegistryDeploySuites { /// @notice The prefix for the constants the alias lib exports. Passed /// rather than derived from the contract name; see `writeAliasLib`. string constant CONSTANT_PREFIX = "ADDRESS_REGISTRY"; - /// @notice Every build: regenerate the rolling snapshot and its alias lib. + /// @notice Every build: regenerate the rolling snapshot, its alias lib and + /// the released-suites lib. function run() external { regenerateCandidate(); LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); + LibRainDeploySnapshot.writeReleasedSuitesLib(vm, "AddressRegistry", candidateSuite().snapshot); } - /// @notice A release: regenerate the rolling snapshot, then freeze it as - /// this release's immutable record. + /// @notice A release: regenerate the rolling snapshot, freeze it as this + /// release's immutable record, then regenerate the declaration of that + /// record. /// /// One invocation, so the ordering is a property of the tool rather than of /// whoever wrote the release command. `LibRainDeploySnapshot.freeze` takes /// the regeneration and runs it FIRST; there is no entry point that freezes /// without regenerating, so a stale freeze has nowhere to come from. /// - /// Not yet wired into `package-release.yaml`, whose `snapshot-generate-cmd` - /// still calls `run()`. Changing that input is out of scope here. + /// The released-suites lib is written from the record AFTER the freeze, so + /// the release being cut is in it. A frozen tag no released suite declares + /// is a release that drops out of every check there is, which is exactly + /// what generating the two from one call removes. function cutRelease() external { string[] memory contractNames = new string[](1); contractNames[0] = "AddressRegistry"; LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); + LibRainDeploySnapshot.writeReleasedSuitesLib(vm, "AddressRegistry", candidateSuite().snapshot); } /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol index b953eb6..bbe3e41 100644 --- a/src/abstract/AddressRegistryDeploySuites.sol +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -9,6 +9,7 @@ import { RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE } from "../generated/candidate/AddressRegistry.sol"; import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; +import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol"; /// @title AddressRegistryDeploySuites /// @notice Everything this repo deploys, declared ONCE. @@ -34,15 +35,22 @@ import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; /// are all inherited. There is deliberately nothing per suite beyond an array /// entry and nothing per network at all. /// -/// From the first `sol-v*` release this is what `script/Build.sol` generates. +/// Half of it is written by hand and half is generated: the candidate below is +/// the declaration, and `releasedSuites()` comes from `script/Build.sol`, which +/// emits it from the frozen record. abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase - /// @dev Empty: no release has been cut, so no `/` snapshot is frozen. - /// `src/generated//` is append-only and `ADDRESS_REGISTRY_ROOT` is - /// still a placeholder, so a release cut now could never be corrected. The - /// rolling `candidate/` snapshot is not frozen and does exist. + /// @dev Generated by `script/Build.sol` from the frozen + /// `src/generated//` record, in the same call that writes it. The + /// record and the declaration of it therefore cannot disagree — which + /// matters because a release missing from the declaration is a release + /// every check quietly stops asking about, while the whole suite stays + /// green. + /// + /// Empty until the first release is cut. The rolling `candidate/` snapshot + /// is not a release and does exist. function releasedSuites() internal pure override returns (DeploySuite[] memory) { - return new DeploySuite[](0); + return LibAddressRegistryReleased.releasedSuites(); } /// @inheritdoc RainDeploySuitesBase diff --git a/src/lib/LibAddressRegistryReleased.sol b/src/lib/LibAddressRegistryReleased.sol new file mode 100644 index 0000000..9670d70 --- /dev/null +++ b/src/lib/LibAddressRegistryReleased.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import {DeploySuite} from "../abstract/RainDeploySuitesBase.sol"; + +/// @title LibAddressRegistryReleased +/// @notice Every frozen release of `AddressRegistry`: one entry per file in +/// the append-only `src/generated//` record, in tag order. +/// +/// The deploy address, code hash, creation code and runtime code of each +/// entry are aliased from that release's own frozen snapshot, so the +/// consensus record is read from the immutable file and from nowhere else. +/// +/// The key, the artifact path and the dependencies are explorer and ordering +/// metadata regenerated from the CURRENT declaration, and are not part of +/// that record. A moved source path retroactively updates every entry's +/// artifact path, which is intended: the alternative is parsing this +/// generated file back in to preserve what it last said. +library LibAddressRegistryReleased { + /// Every frozen release, in tag order. + /// @return suites The released suites. + function releasedSuites() internal pure returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](0); + } +} diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index d6a9912..2159aea 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -5,6 +5,7 @@ pragma solidity ^0.8.25; import {Vm} from "forge-std-1.16.1/src/Vm.sol"; import {LibCodeGen} from "rain-sol-codegen-0.1.6/src/lib/LibCodeGen.sol"; import {LibFs} from "rain-sol-codegen-0.1.6/src/lib/LibFs.sol"; +import {DeploySuite} from "../abstract/RainDeploySuitesBase.sol"; import {LibRainDeploy} from "./LibRainDeploy.sol"; /// Thrown when `[package].version` is not strict `X.Y.Z`. A version like @@ -395,6 +396,275 @@ library LibRainDeploySnapshot { return path; } + /// The release tag a record path sits under. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The tag, e.g. `0_1_7`. + function tagForRecordPath(Vm vm, string memory path) internal pure returns (string memory) { + string[] memory components = vm.split(path, "/"); + return components[components.length - 2]; + } + + /// The contract a record path names. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The contract name, e.g. `AddressRegistry`. + function contractForRecordPath(Vm vm, string memory path) internal pure returns (string memory) { + string[] memory components = vm.split(path, "/"); + return vm.replace(components[components.length - 1], ".sol", ""); + } + + /// The alias prefix a record's four constants are imported under. + /// + /// Both the tag and the contract, because a release freezes every contract + /// it names into one directory: the tag alone collides the moment a repo + /// releases two contracts together, and a collision here is a generated + /// file that does not compile. + /// @param vm The Vm instance for string operations. + /// @param path A record file, as `frozenSnapshotPaths` returns it. + /// @return The prefix, e.g. `AddressRegistry_0_1_7`. + function releasedConstantPrefix(Vm vm, string memory path) internal pure returns (string memory) { + return string.concat(contractForRecordPath(vm, path), "_", tagForRecordPath(vm, path)); + } + + /// Whether record file `a` is emitted before record file `b`: by release + /// tag, then by path. + /// + /// Tags compare as VERSIONS rather than as text — `0_10_0` follows `0_9_0` + /// as a release and precedes it as a string, so a text comparison misorders + /// every record that outlives a single-digit minor. Both tags are `isTag`, + /// so every component is a run of digits `parseUint` reads. + /// + /// The tie break is the path, byte for byte, so two contracts frozen under + /// one tag have an order at all. The walk's own order is the filesystem's, + /// and a generated file that changes with it is a diff on every build. + /// @param vm The Vm instance for string operations. + /// @param a A record file. + /// @param b A record file. + /// @return Whether `a` precedes `b`. + function recordPrecedes(Vm vm, string memory a, string memory b) internal pure returns (bool) { + string[] memory left = vm.split(tagForRecordPath(vm, a), "_"); + string[] memory right = vm.split(tagForRecordPath(vm, b), "_"); + for (uint256 i = 0; i < left.length; i++) { + uint256 leftComponent = vm.parseUint(left[i]); + uint256 rightComponent = vm.parseUint(right[i]); + if (leftComponent != rightComponent) { + return leftComponent < rightComponent; + } + } + + bytes memory aBytes = bytes(a); + bytes memory bBytes = bytes(b); + uint256 shortest = aBytes.length < bBytes.length ? aBytes.length : bBytes.length; + for (uint256 i = 0; i < shortest; i++) { + if (aBytes[i] != bBytes[i]) { + return aBytes[i] < bBytes[i]; + } + } + return aBytes.length < bBytes.length; + } + + /// The record's files in the order they are emitted. + /// + /// An insertion sort, because a record is one directory per release and + /// nothing sorts a list that short faster than it takes to say so. + /// @param vm The Vm instance for string operations. + /// @param paths The record's files, in any order. + /// @return sorted The same files, in release order. + function sortedRecordPaths(Vm vm, string[] memory paths) internal pure returns (string[] memory sorted) { + sorted = new string[](paths.length); + for (uint256 i = 0; i < paths.length; i++) { + uint256 j = i; + while (j > 0 && recordPrecedes(vm, paths[i], sorted[j - 1])) { + sorted[j] = sorted[j - 1]; + j--; + } + sorted[j] = paths[i]; + } + } + + /// The import block of a generated released-suites lib. + /// + /// One aliased import per record file, carrying all four consensus fields. + /// A released entry can therefore only say what its own frozen snapshot + /// says — there is no path by which a released address, code hash, creation + /// code or runtime code is written anywhere but into the immutable record. + /// @param vm The Vm instance for string operations. + /// @param paths The record's files, in the order they are emitted. + /// @return imports The import block. + function releasedImportBlock(Vm vm, string[] memory paths) internal pure returns (string memory imports) { + imports = "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n"; + for (uint256 i = 0; i < paths.length; i++) { + string memory prefix = releasedConstantPrefix(vm, paths[i]); + imports = string.concat( + imports, + "import {\n DEPLOYED_ADDRESS as ", + prefix, + "_DEPLOYED_ADDRESS,\n BYTECODE_HASH as ", + prefix, + "_BYTECODE_HASH,\n CREATION_CODE as ", + prefix, + "_CREATION_CODE,\n RUNTIME_CODE as ", + prefix, + "_RUNTIME_CODE\n} from \"../generated/", + tagForRecordPath(vm, paths[i]), + "/", + contractForRecordPath(vm, paths[i]), + ".sol\";\n\n" + ); + } + } + + /// The library block of a generated released-suites lib. + /// + /// Four fields per entry alias the frozen snapshot. The other three come + /// from `template`, the candidate declaration, and are regenerated from it + /// on every build: they are explorer and ordering metadata rather than + /// consensus, and preserving what a previous generation wrote would mean + /// parsing generated Solidity back in. + /// + /// The key is the template's with the tag appended, so every entry is + /// unique and `allSuites`'s duplicate check is satisfied by construction + /// rather than by whoever writes the declaration. + /// @param vm The Vm instance for string operations. + /// @param libraryName The generated library's name. + /// @param contractName The contract the released record describes. + /// @param paths The record's files, in the order they are emitted. + /// @param template The candidate declaration the metadata comes from. + /// @return The library block. + function releasedLibraryBlock( + Vm vm, + string memory libraryName, + string memory contractName, + string[] memory paths, + DeploySuite memory template + ) internal pure returns (string memory) { + string memory entries = ""; + for (uint256 i = 0; i < paths.length; i++) { + string memory index = vm.toString(i); + string memory dependencies = string.concat("dependencies", index); + string memory prefix = releasedConstantPrefix(vm, paths[i]); + + entries = string.concat( + entries, + " address[] memory ", + dependencies, + " = new address[](", + vm.toString(template.dependencies.length), + ");\n" + ); + for (uint256 j = 0; j < template.dependencies.length; j++) { + entries = string.concat( + entries, + " ", + dependencies, + "[", + vm.toString(j), + "] = address(", + vm.toString(template.dependencies[j]), + ");\n" + ); + } + + entries = string.concat( + entries, + " suites[", + index, + "] = DeploySuite({\n suite: \"", + template.suite, + "@", + tagForRecordPath(vm, paths[i]), + "\",\n creationCode: ", + prefix, + "_CREATION_CODE,\n storedDeployedAddress: ", + prefix, + "_DEPLOYED_ADDRESS,\n storedBytecodeHash: ", + prefix, + "_BYTECODE_HASH,\n storedRuntimeCode: ", + prefix, + "_RUNTIME_CODE,\n artifactPath: \"", + template.artifactPath, + "\",\n dependencies: ", + dependencies, + "\n });\n" + ); + } + + return string.concat( + "/// @title ", + libraryName, + "\n/// @notice Every frozen release of `", + contractName, + "`: one entry per file in\n", + "/// the append-only `src/generated//` record, in tag order.\n///\n", + "/// The deploy address, code hash, creation code and runtime code of each\n", + "/// entry are aliased from that release's own frozen snapshot, so the\n", + "/// consensus record is read from the immutable file and from nowhere else.\n///\n", + "/// The key, the artifact path and the dependencies are explorer and ordering\n", + "/// metadata regenerated from the CURRENT declaration, and are not part of\n", + "/// that record. A moved source path retroactively updates every entry's\n", + "/// artifact path, which is intended: the alternative is parsing this\n", + "/// generated file back in to preserve what it last said.\nlibrary ", + libraryName, + " {\n /// Every frozen release, in tag order.\n", + " /// @return suites The released suites.\n", + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n", + " suites = new DeploySuite[](", + vm.toString(paths.length), + ");\n", + entries, + " }\n}\n" + ); + } + + /// Generate the released-suites lib for a repo's frozen record: the + /// declaration of what this repo has released, emitted from the record + /// itself. + /// + /// The record and the declaration of it are produced by ONE call, so a + /// release that `src/generated//` holds is a release `releasedSuites()` + /// names. A hand-written declaration is the one thing that can silently + /// drop a release out of every check there is — `RainDeployVerifySnapshot` + /// checks the declaration against the record precisely because nothing else + /// would notice, and generating both here is what makes that check pass by + /// construction rather than by remembering. + /// + /// Written beside the alias lib, under the same `Lib` naming, so + /// all generated non-snapshot Solidity is in one directory. + /// + /// Four fields per entry come from the frozen snapshot and three from + /// `template`. Those three — the key, the artifact path and the + /// dependencies — are explorer and ordering metadata regenerated from the + /// CURRENT declaration on every build, NOT part of the frozen consensus + /// record. Moving a source file retroactively updates the artifact path of + /// every entry, including releases cut years ago, which is intended: the + /// alternative is parsing the previously generated Solidity back in to + /// preserve what it said. + /// @param vm The Vm instance for file operations. + /// @param contractName The contract the released record describes. + /// @param template The candidate declaration the metadata comes from. + /// @return The path written. + function writeReleasedSuitesLib(Vm vm, string memory contractName, DeploySuite memory template) + internal + returns (string memory) + { + string memory libraryName = string.concat("Lib", contractName, "Released"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + string[] memory paths = sortedRecordPaths(vm, frozenSnapshotPaths(vm, LIB_FS_ROOT)); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + path, + string.concat( + LibCodeGen.filePrefix(), + "\n", + releasedImportBlock(vm, paths), + releasedLibraryBlock(vm, libraryName, contractName, paths, template) + ) + ); + return path; + } + /// Regenerate the rolling snapshot and freeze it as this release's record, /// in that order, in one call. /// diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 6a3dff8..e69dc30 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {DeploySuite} from "../../../src/abstract/RainDeploySuitesBase.sol"; import { EmptyRelease, LibRainDeploySnapshot, @@ -220,6 +221,341 @@ contract LibRainDeploySnapshotTest is Test { vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); } + /// Where the released-lib record fixture is built. Its own tree rather + /// than `FIXTURE_ROOT`: forge runs the tests in a contract concurrently, + /// and two of them writing one record root see each other's releases. + string constant RELEASED_FIXTURE_ROOT = "test/generated-released"; + + /// The contract every emitter test emits a released lib for. + string constant EMITTED_CONTRACT = "AddressRegistry"; + + /// The library every emitter test emits, derived from `EMITTED_CONTRACT` + /// exactly as `writeReleasedSuitesLib` derives it. + string constant EMITTED_LIBRARY = "LibAddressRegistryReleased"; + + /// The candidate declaration the emitted metadata comes from. + /// + /// Every CONSENSUS field is zero, so an emitter that took one of the four + /// frozen fields from the template rather than from the record would emit a + /// zero and be seen doing it. Only the key, the artifact path and the + /// dependencies are meant to come from here. + /// @return The template. + function emitterTemplate() internal pure returns (DeploySuite memory) { + return DeploySuite({ + suite: "address-registry", + creationCode: "", + storedDeployedAddress: address(0), + storedBytecodeHash: bytes32(0), + storedRuntimeCode: "", + artifactPath: "src/concrete/AddressRegistry.sol:AddressRegistry", + dependencies: new address[](0) + }); + } + + /// A record of `count` releases, `0_0_1` upwards, as + /// `frozenSnapshotPaths` spells them. + /// @param count How many releases. + /// @return paths The record's files. + function recordOf(uint256 count) internal pure returns (string[] memory paths) { + paths = new string[](count); + for (uint256 i = 0; i < count; i++) { + paths[i] = string.concat( + LibRainDeploySnapshot.LIB_FS_ROOT, "/0_0_", vm.toString(i + 1), "/", EMITTED_CONTRACT, ".sol" + ); + } + } + + /// The aliased import one release contributes. + /// @param tag The release tag. + /// @return The import statement, and the blank line after it. + function expectedImport(string memory tag) internal pure returns (string memory) { + return string.concat( + "import {\n DEPLOYED_ADDRESS as AddressRegistry_", + tag, + "_DEPLOYED_ADDRESS,\n BYTECODE_HASH as AddressRegistry_", + tag, + "_BYTECODE_HASH,\n CREATION_CODE as AddressRegistry_", + tag, + "_CREATION_CODE,\n RUNTIME_CODE as AddressRegistry_", + tag, + "_RUNTIME_CODE\n} from \"../generated/", + tag, + "/AddressRegistry.sol\";\n\n" + ); + } + + /// The generated library's text from its title down to the line that opens + /// `releasedSuites`. The same for every record, so the per-record + /// assertions below are about the entries. + string constant EXPECTED_LIBRARY_HEADER = "/// @title LibAddressRegistryReleased\n" + "/// @notice Every frozen release of `AddressRegistry`: one entry per file in\n" + "/// the append-only `src/generated//` record, in tag order.\n" "///\n" + "/// The deploy address, code hash, creation code and runtime code of each\n" + "/// entry are aliased from that release's own frozen snapshot, so the\n" + "/// consensus record is read from the immutable file and from nowhere else.\n" "///\n" + "/// The key, the artifact path and the dependencies are explorer and ordering\n" + "/// metadata regenerated from the CURRENT declaration, and are not part of\n" + "/// that record. A moved source path retroactively updates every entry's\n" + "/// artifact path, which is intended: the alternative is parsing this\n" + "/// generated file back in to preserve what it last said.\n" "library LibAddressRegistryReleased {\n" + " /// Every frozen release, in tag order.\n" " /// @return suites The released suites.\n" + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n"; + + /// The entry one release contributes, with no dependencies. + /// @param index The entry's index. + /// @param tag The release tag. + /// @return The entry's statements. + function expectedEntry(string memory index, string memory tag) internal pure returns (string memory) { + return string.concat( + " address[] memory dependencies", + index, + " = new address[](0);\n suites[", + index, + "] = DeploySuite({\n suite: \"address-registry@", + tag, + "\",\n creationCode: AddressRegistry_", + tag, + "_CREATION_CODE,\n storedDeployedAddress: AddressRegistry_", + tag, + "_DEPLOYED_ADDRESS,\n storedBytecodeHash: AddressRegistry_", + tag, + "_BYTECODE_HASH,\n storedRuntimeCode: AddressRegistry_", + tag, + "_RUNTIME_CODE,\n artifactPath: \"src/concrete/AddressRegistry.sol:AddressRegistry\",\n", + " dependencies: dependencies", + index, + "\n });\n" + ); + } + + /// The import block MUST carry all four consensus constants of every record + /// file and nothing else, aliased so that two releases of one contract, and + /// two contracts in one release, are all distinct names. + /// + /// The four aliased fields are the whole of what a released entry says + /// about consensus, so an import that goes missing is a field that silently + /// falls back to whatever else is in scope. + function testReleasedImportBlockAliasesEveryRecord() external pure { + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(0)), + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n" + ); + + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(1)), + string.concat( + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", expectedImport("0_0_1") + ) + ); + + assertEq( + LibRainDeploySnapshot.releasedImportBlock(vm, recordOf(2)), + string.concat( + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", + expectedImport("0_0_1"), + expectedImport("0_0_2") + ) + ); + } + + /// The library block MUST declare one suite per record file, taking the + /// four consensus fields from that file's aliased constants and the other + /// three from the template. + /// + /// A record with nothing in it is the state of every deploy repo before its + /// first release, including this one, and it MUST still emit a compiling + /// library: the declaration is imported by ordinary source, so a repo that + /// could not emit one before its first release could not build. + function testReleasedLibraryBlockDeclaresEveryRecord() external pure { + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(0), emitterTemplate() + ), + string.concat(EXPECTED_LIBRARY_HEADER, " suites = new DeploySuite[](0);\n", " }\n}\n") + ); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(1), emitterTemplate() + ), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](1);\n", + expectedEntry("0", "0_0_1"), + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(2), emitterTemplate() + ), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](2);\n", + expectedEntry("0", "0_0_1"), + expectedEntry("1", "0_0_2"), + " }\n}\n" + ) + ); + } + + /// The key MUST be the template's with the tag appended, so a repo with + /// several releases of one contract declares several DISTINCT suites. + /// `allSuites` refuses a duplicate key, so a key that did not carry the tag + /// would make the second release unreachable and the whole declaration + /// revert. + function testReleasedLibraryBlockKeysAreUniquePerRelease() external pure { + string memory emitted = LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(2), emitterTemplate() + ); + + assertTrue(vm.contains(emitted, "suite: \"address-registry@0_0_1\"")); + assertTrue(vm.contains(emitted, "suite: \"address-registry@0_0_2\"")); + } + + /// The dependencies MUST be the template's, element for element. They are + /// what a broadcast checks is already on chain before it deploys anything, + /// so an entry that dropped them would deploy a suite whose constructor + /// reads an address with no code. + function testReleasedLibraryBlockCarriesTheTemplateDependencies() external pure { + DeploySuite memory template = emitterTemplate(); + template.dependencies = new address[](2); + template.dependencies[0] = address(0xdead); + template.dependencies[1] = address(0xbeef); + + assertEq( + LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, recordOf(1), template), + string.concat( + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](1);\n", + " address[] memory dependencies0 = new address[](2);\n", + " dependencies0[0] = address(", + vm.toString(address(0xdead)), + ");\n dependencies0[1] = address(", + vm.toString(address(0xbeef)), + ");\n suites[0] = DeploySuite({\n suite: \"address-registry@0_0_1\",\n", + " creationCode: AddressRegistry_0_0_1_CREATION_CODE,\n", + " storedDeployedAddress: AddressRegistry_0_0_1_DEPLOYED_ADDRESS,\n", + " storedBytecodeHash: AddressRegistry_0_0_1_BYTECODE_HASH,\n", + " storedRuntimeCode: AddressRegistry_0_0_1_RUNTIME_CODE,\n", + " artifactPath: \"src/concrete/AddressRegistry.sol:AddressRegistry\",\n", + " dependencies: dependencies0\n });\n", + " }\n}\n" + ) + ); + } + + /// Releases MUST be emitted in the order they were cut, comparing tags as + /// VERSIONS. `0_10_0` follows `0_9_0` as a release and precedes it as text, + /// so a sort on the raw string misorders every record that outlives a + /// single-digit component. + /// + /// Files frozen under one tag are ordered by path, so the emitted file does + /// not change with whatever order the filesystem happened to hand the walk + /// — a generated file that moves on its own is a diff on every build. A + /// record directory holds every file in it and there is no extension to + /// filter on, so one name being the whole start of another is a state the + /// order has to settle too. + function testSortedRecordPathsOrdersTagsAsVersions() external pure { + string[] memory paths = new string[](5); + paths[0] = "src/generated/1_0_0/AddressRegistry.sol"; + paths[1] = "src/generated/0_9_0/AddressRegistry.sol.orig"; + paths[2] = "src/generated/0_9_0/Second.sol"; + paths[3] = "src/generated/0_10_0/AddressRegistry.sol"; + paths[4] = "src/generated/0_9_0/AddressRegistry.sol"; + + string[] memory sorted = LibRainDeploySnapshot.sortedRecordPaths(vm, paths); + + assertEq(sorted.length, 5); + assertEq(sorted[0], "src/generated/0_9_0/AddressRegistry.sol"); + assertEq(sorted[1], "src/generated/0_9_0/AddressRegistry.sol.orig"); + assertEq(sorted[2], "src/generated/0_9_0/Second.sol"); + assertEq(sorted[3], "src/generated/0_10_0/AddressRegistry.sol"); + assertEq(sorted[4], "src/generated/1_0_0/AddressRegistry.sol"); + } + + /// The emitted declaration MUST name every release in the record and + /// nothing else in the tree. + /// + /// Driven through the same pipeline `writeReleasedSuitesLib` runs — + /// `frozenSnapshotPaths`, `sortedRecordPaths`, then the emitters — because + /// the writer takes no output root and the repo's real record is the only + /// one it can read. `frozenSnapshotPaths` is where the record root is a + /// parameter, so that is where a fixture tree goes in. + function testReleasedLibReadsTheRecordAndNothingElse() external { + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/AddressRegistry.sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/AddressRegistry.sol")); + // Not releases: the rolling snapshot, and a scratch directory a test or + // a human left behind. + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/AddressRegistry.sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/collision-guard/AddressRegistry.sol")); + + string[] memory paths = LibRainDeploySnapshot.sortedRecordPaths( + vm, LibRainDeploySnapshot.frozenSnapshotPaths(vm, RELEASED_FIXTURE_ROOT) + ); + string memory emitted = string.concat( + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate()) + ); + + assertEq(paths.length, 2); + assertEq( + emitted, + string.concat( + "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", + expectedImport("0_9_0"), + expectedImport("0_10_0"), + EXPECTED_LIBRARY_HEADER, + " suites = new DeploySuite[](2);\n", + expectedEntry("0", "0_9_0"), + expectedEntry("1", "0_10_0"), + " }\n}\n" + ) + ); + assertFalse(vm.contains(emitted, LibRainDeploySnapshot.CANDIDATE)); + assertFalse(vm.contains(emitted, "collision-guard")); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RELEASED_FIXTURE_ROOT, true); + } + + /// The released lib MUST land beside the alias lib, under the name derived + /// from the contract, holding exactly the prefix, imports and library the + /// emitters produce. + /// + /// Run against this repo's REAL record, which is the one root the writer + /// reads, so what it writes is the committed generated file — that is the + /// whole of what makes a stale generated file a test failure rather than a + /// silent one. Restored afterwards, because a run that failed between the + /// write and the assertion would otherwise leave the tree dirty. + function testWriteReleasedSuitesLibWritesTheLibAtItsPath() external { + string memory path = "src/lib/LibAddressRegistryReleased.sol"; + string memory before = vm.readFile(path); + + assertEq(LibRainDeploySnapshot.writeReleasedSuitesLib(vm, EMITTED_CONTRACT, emitterTemplate()), path); + + string[] memory paths = + LibRainDeploySnapshot.sortedRecordPaths(vm, LibRainDeploySnapshot.frozenSnapshotPaths(vm, "src/generated")); + assertEq( + vm.readFile(path), + string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock( + vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate() + ) + ) + ); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, before); + } + /// A freeze that names no contracts MUST be refused. It would write /// nothing, report success, and leave `/` there — and an empty /// `/` is a frozen tag, so the real cut of that release could never From dd2d93c817d43f8a9ded0f61aa234f98a1a38fa5 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 07:46:06 +0000 Subject: [PATCH 26/29] fix(release): a released lib describes one contract, not the record whole `writeReleasedSuitesLib` read the whole frozen record, so a repo freezing two contracts under one tag emitted both into one released lib, both carrying that lib's suite key, colliding on `template.suite@tag` and reverting `allSuites()` with `DuplicateDeploySuite`. `recordPathsForContract` selects the contract the lib names out of the record, then sorts. The record root is a parameter, so the writer is driven against a fixture record rather than only against the repo's own, which holds nothing until the first release is cut. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 17 +-- script/Build.sol | 8 +- src/abstract/RainDeployVerifySnapshot.sol | 18 ++- src/lib/LibRainDeploySnapshot.sol | 56 ++++++++- test/src/lib/LibRainDeploySnapshot.t.sol | 142 +++++++++++++++++----- 5 files changed, 189 insertions(+), 52 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 38275b8..11e6d1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,8 @@ hand-edit a generated file. `src/generated//` directories are the FROZEN record: what each release deployed, written once by `cutRelease()` and never again. That tree is the only description of what this repo has released that cannot fall behind, which is why -`RainDeployVerifySnapshot` checks the hand-written `releasedSuites()` against -it. `LibRainDeploySnapshot.frozenSnapshotPaths` is the walk: every file inside a +`RainDeployVerifySnapshot` checks the generated `releasedSuites()` against it. +`LibRainDeploySnapshot.frozenSnapshotPaths` is the walk: every file inside a release-tag directory, where a release tag is exactly what `tagForVersion` produces — so `candidate/`, a scratch directory and a `0_1_7-rc1` nobody could have frozen all fall out under the same rule, and there is no name to remember @@ -237,11 +237,14 @@ Four groups, sorted by what they are anchored to: 3. **Anchored to the record** (`RainDeployVerifySnapshot`) — every file in the append-only `src/generated//` tree is declared by a released suite, matched by the address that file's creation code derives. `releasedSuites()` - is hand written and everything anchored to a chain reads it, so a frozen tag - nobody added to it is a release that quietly drops out of every check there - is. Matched against RELEASED suites only: a release and the candidate are - byte-identical from the moment the release is cut, so matching the whole - declaration would let the candidate declare a release. + is generated from that same record and everything anchored to a chain reads + it, so a frozen tag it does not name is a release that quietly drops out of + every check there is — which is what this catches when the generated file is + hand edited, a record directory arrives out of band, or nobody re-ran the + generator after the record moved. Matched against RELEASED suites only: a + release and the candidate are byte-identical from the moment the release is + cut, so matching the whole declaration would let the candidate declare a + release. 4. **Anchored to chain** (`RainDeployVerifyChain`) — across `supportedNetworks()`, every RELEASED version's derived address carries code with its derived code hash. The only check that catches "never deployed" or diff --git a/script/Build.sol b/script/Build.sol index 4f309fb..0a75a2e 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -51,7 +51,9 @@ contract Build is Script, AddressRegistryDeploySuites { function run() external { regenerateCandidate(); LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); - LibRainDeploySnapshot.writeReleasedSuitesLib(vm, "AddressRegistry", candidateSuite().snapshot); + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, "AddressRegistry", candidateSuite().snapshot + ); } /// @notice A release: regenerate the rolling snapshot, freeze it as this @@ -72,7 +74,9 @@ contract Build is Script, AddressRegistryDeploySuites { contractNames[0] = "AddressRegistry"; LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); - LibRainDeploySnapshot.writeReleasedSuitesLib(vm, "AddressRegistry", candidateSuite().snapshot); + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, "AddressRegistry", candidateSuite().snapshot + ); } /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index c2c7499..1c3045d 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -84,9 +84,10 @@ error FrozenSnapshotUnreadable(string path); /// append-only `src/generated//` directories — is declared by a released /// suite. This is the one check that is about the DECLARATION rather than about /// what a declared suite records, and it exists because everything anchored to -/// a chain reads `releasedSuites()`, which a human maintains. A release missing -/// from it is not caught anywhere else, by anything: it simply stops being -/// checked, and every check there is stays green. +/// a chain reads `releasedSuites()`, which is a separate file from the record +/// it describes. A release missing from it is not caught anywhere else, by +/// anything: it simply stops being checked, and every check there is stays +/// green. /// /// None of the three can catch a suite that was never deployed, or that is no /// longer deployed. Only `RainDeployVerifyChain` can, and nothing here is a @@ -152,13 +153,20 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { /// Checks the frozen record against the released declaration: every file in /// the record is declared by a released suite. /// - /// `releasedSuites()` is hand written, and everything anchored to a chain - /// reads it. A frozen tag nobody added to it is therefore not a missing + /// `releasedSuites()` is a generated file, and everything anchored to a + /// chain reads it. A frozen tag it does not name is therefore not a missing /// entry that shows up as a failure somewhere — it is a release that drops /// out of every check there is, silently and permanently, while the whole /// suite stays green. The record is the only thing that can say it /// happened, so the declaration is checked against the record. /// + /// Emitting the declaration from the record is what makes the two agree in + /// the first place. This is what catches the ways they still come apart: a + /// hand edit to the generated file, a record directory that arrived out of + /// band, and a generated file nobody regenerated after the record moved. + /// Nothing in CI regenerates anything, so a stale generated file is caught + /// here or not at all. + /// /// Matched against the RELEASED suites alone, deliberately. A release and /// the rolling candidate are byte-identical from the moment the release is /// cut until source next moves, so a match against every declared suite diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 2159aea..c8c684f 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -483,6 +483,45 @@ library LibRainDeploySnapshot { } } + /// One contract's releases out of a record, in tag order. + /// + /// The record holds every contract a repo has ever frozen, and `freeze` + /// takes a LIST of contract names, so a release of two contracts writes two + /// files under one tag. A released-suites lib describes one contract, so it + /// has to select rather than take the record whole: emitting another + /// contract's snapshot into it would give that entry this contract's suite + /// key, which collides with this contract's own entry for the same tag and + /// reverts `allSuites()` with `DuplicateDeploySuite`. A repo with one + /// contract never sees it, which is exactly why it is selected here rather + /// than left to be discovered by the first repo that freezes two. + /// @param vm The Vm instance for file operations. + /// @param recordRoot The record root to read releases from. + /// @param contractName The contract to select. + /// @return This contract's record paths, in tag order. + function recordPathsForContract(Vm vm, string memory recordRoot, string memory contractName) + internal + view + returns (string[] memory) + { + string[] memory paths = frozenSnapshotPaths(vm, recordRoot); + + string[] memory found = new string[](paths.length); + uint256 count = 0; + for (uint256 i = 0; i < paths.length; i++) { + if (keccak256(bytes(contractForRecordPath(vm, paths[i]))) != keccak256(bytes(contractName))) { + continue; + } + found[count] = paths[i]; + count++; + } + + string[] memory selected = new string[](count); + for (uint256 i = 0; i < count; i++) { + selected[i] = found[i]; + } + return sortedRecordPaths(vm, selected); + } + /// The import block of a generated released-suites lib. /// /// One aliased import per record file, carrying all four consensus fields. @@ -641,16 +680,23 @@ library LibRainDeploySnapshot { /// alternative is parsing the previously generated Solidity back in to /// preserve what it said. /// @param vm The Vm instance for file operations. + /// @param recordRoot The record root to read releases from — `LIB_FS_ROOT` + /// for a repo's real record. A parameter for the same reason + /// `frozenSnapshotPaths` takes one: a writer that can only be pointed at + /// the real record can only be tested against it, and a repo that has cut + /// no release has nothing there to test against. /// @param contractName The contract the released record describes. /// @param template The candidate declaration the metadata comes from. /// @return The path written. - function writeReleasedSuitesLib(Vm vm, string memory contractName, DeploySuite memory template) - internal - returns (string memory) - { + function writeReleasedSuitesLib( + Vm vm, + string memory recordRoot, + string memory contractName, + DeploySuite memory template + ) internal returns (string memory) { string memory libraryName = string.concat("Lib", contractName, "Released"); string memory path = string.concat("src/lib/", libraryName, ".sol"); - string[] memory paths = sortedRecordPaths(vm, frozenSnapshotPaths(vm, LIB_FS_ROOT)); + string[] memory paths = recordPathsForContract(vm, recordRoot, contractName); //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile( diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index e69dc30..acfd746 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -226,6 +226,22 @@ contract LibRainDeploySnapshotTest is Test { /// and two of them writing one record root see each other's releases. string constant RELEASED_FIXTURE_ROOT = "test/generated-released"; + /// Where the selection fixture's record is built, for the same reason + /// `RELEASED_FIXTURE_ROOT` is not `FIXTURE_ROOT`. + string constant SELECTED_FIXTURE_ROOT = "test/generated-selected"; + + /// The contract the fixture record freezes, and the one the writer is + /// pointed at there. NOT this repo's own `AddressRegistry`: the writer + /// derives the file it writes from the contract name, and the committed + /// declaration is not a file a fixture gets to overwrite. + string constant FIXTURE_CONTRACT = "MockDeployable"; + + /// A second contract frozen under one of the fixture record's tags, as a + /// release naming two contracts writes it. Its name starts with + /// `FIXTURE_CONTRACT`, so a selection that matched on a prefix would take + /// it too. + string constant FIXTURE_CONTRACT_SECOND = "MockDeployableV2"; + /// The contract every emitter test emits a released lib for. string constant EMITTED_CONTRACT = "AddressRegistry"; @@ -475,47 +491,102 @@ contract LibRainDeploySnapshotTest is Test { assertEq(sorted[4], "src/generated/1_0_0/AddressRegistry.sol"); } - /// The emitted declaration MUST name every release in the record and - /// nothing else in the tree. + /// A record holds every contract a repo has ever frozen, and a released lib + /// describes ONE of them. The selection MUST be by whole contract name, + /// over releases only, in tag order. + /// + /// Two contracts under one tag is what a release naming two of them writes. + /// Emitting the other one's snapshot into this contract's lib would give it + /// this contract's suite key, colliding with this contract's own entry for + /// that tag and reverting `allSuites()` for everything downstream. /// - /// Driven through the same pipeline `writeReleasedSuitesLib` runs — - /// `frozenSnapshotPaths`, `sortedRecordPaths`, then the emitters — because - /// the writer takes no output root and the repo's real record is the only - /// one it can read. `frozenSnapshotPaths` is where the record root is a - /// parameter, so that is where a fixture tree goes in. - function testReleasedLibReadsTheRecordAndNothingElse() external { - writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/AddressRegistry.sol")); - writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/AddressRegistry.sol")); + /// The fixture record is written newest tag first, so the order returned is + /// the sort's and not whatever order the walk came back in. + function testRecordPathsForContractSelectsOneContractInTagOrder() external { + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); // Not releases: the rolling snapshot, and a scratch directory a test or // a human left behind. - writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/AddressRegistry.sol")); - writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/collision-guard/AddressRegistry.sol")); + writeFixture( + string.concat(SELECTED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/", FIXTURE_CONTRACT, ".sol") + ); + writeFixture(string.concat(SELECTED_FIXTURE_ROOT, "/collision-guard/", FIXTURE_CONTRACT, ".sol")); + + string[] memory selected = + LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT); + + assertEq(selected.length, 2); + assertEq(selected[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + assertEq(selected[1], string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); - string[] memory paths = LibRainDeploySnapshot.sortedRecordPaths( - vm, LibRainDeploySnapshot.frozenSnapshotPaths(vm, RELEASED_FIXTURE_ROOT) + // The contract asked for is the contract selected, and the one frozen + // beside it has its own single release rather than none. + string[] memory second = + LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT_SECOND); + + assertEq(second.length, 1); + assertEq(second[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(SELECTED_FIXTURE_ROOT, true); + } + + /// The writer MUST emit the record root it is HANDED, selected down to the + /// contract it names, and nothing else in that tree. + /// + /// The record root is a parameter, so a fixture record goes straight into + /// the writer: pointed at a root it was not given, or handed the record + /// whole, the file it writes says so. The fixture record is written newest + /// tag first, so the emitted order is the sort's and not the walk's. + /// + /// A fixture contract name, so the file written is the fixture's own and + /// not this repo's committed declaration. Removed at the end, because an + /// emitted lib importing a record that only a test wrote does not compile. + function testWriteReleasedSuitesLibReadsTheRecordItIsHanded() external { + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); + // Not releases: the rolling snapshot, and a scratch directory a test or + // a human left behind. + writeFixture( + string.concat(RELEASED_FIXTURE_ROOT, "/", LibRainDeploySnapshot.CANDIDATE, "/", FIXTURE_CONTRACT, ".sol") ); - string memory emitted = string.concat( - LibRainDeploySnapshot.releasedImportBlock(vm, paths), - LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate()) + writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/collision-guard/", FIXTURE_CONTRACT, ".sol")); + + string[] memory paths = new string[](2); + paths[0] = string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol"); + paths[1] = string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol"); + + string memory libraryName = string.concat("Lib", FIXTURE_CONTRACT, "Released"); + string memory path = string.concat("src/lib/", libraryName, ".sol"); + + assertEq( + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate() + ), + path ); - assertEq(paths.length, 2); + string memory emitted = vm.readFile(path); assertEq( emitted, string.concat( - "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n", - expectedImport("0_9_0"), - expectedImport("0_10_0"), - EXPECTED_LIBRARY_HEADER, - " suites = new DeploySuite[](2);\n", - expectedEntry("0", "0_9_0"), - expectedEntry("1", "0_10_0"), - " }\n}\n" + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, libraryName, FIXTURE_CONTRACT, paths, emitterTemplate()) ) ); + assertFalse(vm.contains(emitted, FIXTURE_CONTRACT_SECOND)); assertFalse(vm.contains(emitted, LibRainDeploySnapshot.CANDIDATE)); assertFalse(vm.contains(emitted, "collision-guard")); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeFile(path); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(RELEASED_FIXTURE_ROOT, true); } @@ -524,19 +595,24 @@ contract LibRainDeploySnapshotTest is Test { /// from the contract, holding exactly the prefix, imports and library the /// emitters produce. /// - /// Run against this repo's REAL record, which is the one root the writer - /// reads, so what it writes is the committed generated file — that is the - /// whole of what makes a stale generated file a test failure rather than a - /// silent one. Restored afterwards, because a run that failed between the - /// write and the assertion would otherwise leave the tree dirty. + /// Run against this repo's REAL record and its real contract, so what it + /// writes is the committed generated file — that is the whole of what makes + /// a stale generated file a test failure rather than a silent one. Restored + /// afterwards, because a run that failed between the write and the + /// assertion would otherwise leave the tree dirty. function testWriteReleasedSuitesLibWritesTheLibAtItsPath() external { string memory path = "src/lib/LibAddressRegistryReleased.sol"; string memory before = vm.readFile(path); - assertEq(LibRainDeploySnapshot.writeReleasedSuitesLib(vm, EMITTED_CONTRACT, emitterTemplate()), path); + assertEq( + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT, emitterTemplate() + ), + path + ); string[] memory paths = - LibRainDeploySnapshot.sortedRecordPaths(vm, LibRainDeploySnapshot.frozenSnapshotPaths(vm, "src/generated")); + LibRainDeploySnapshot.recordPathsForContract(vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT); assertEq( vm.readFile(path), string.concat( From eb0b3f54d6e39d4bf09a3757184821787877bfb9 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 13:18:07 +0000 Subject: [PATCH 27/29] fix(verify): read the declaration the compiler reads, and restore the tree before asserting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recordedDeployedAddress` accepted any line CONTAINING the declaration text, so a commented-out declaration parked above the real one was read as the record's deploy address. That is the hand edit group 3 exists to catch: the file would match a released suite on an address it does not declare. Matched from the start of the line now, which is where every generated snapshot puts it. Every test that writes into the repo tree undid it after assertions that revert, so the undo ran in every case except a failure — the only case where the tree is dirty. Reads first, restore, then assert. `script/Build.sol` names the contract once and both entry points end at one `regenerateLibs`, so neither can regenerate the alias lib without the released suites lib. The missing-root test reads its own root: `FIXTURE_ROOT` is built and torn down by another test in the same contract, which forge runs concurrently. Co-Authored-By: Claude Opus 5 (1M context) --- script/Build.sol | 26 ++-- src/abstract/RainDeployVerifySnapshot.sol | 11 +- .../abstract/RainDeployVerifySnapshot.t.sol | 43 +++++++ test/src/lib/LibRainDeploySnapshot.t.sol | 118 +++++++++--------- 4 files changed, 130 insertions(+), 68 deletions(-) diff --git a/script/Build.sol b/script/Build.sol index 0a75a2e..947b655 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -46,14 +46,17 @@ contract Build is Script, AddressRegistryDeploySuites { /// rather than derived from the contract name; see `writeAliasLib`. string constant CONSTANT_PREFIX = "ADDRESS_REGISTRY"; + /// @notice The contract this repo deploys. Named once, because the + /// snapshot, the alias lib, the released-suites lib and the freeze all + /// describe the same contract, and a second spelling of it is a second + /// spelling that drifts. + string constant CONTRACT_NAME = "AddressRegistry"; + /// @notice Every build: regenerate the rolling snapshot, its alias lib and /// the released-suites lib. function run() external { regenerateCandidate(); - LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); - LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, "AddressRegistry", candidateSuite().snapshot - ); + regenerateLibs(); } /// @notice A release: regenerate the rolling snapshot, freeze it as this @@ -71,11 +74,18 @@ contract Build is Script, AddressRegistryDeploySuites { /// what generating the two from one call removes. function cutRelease() external { string[] memory contractNames = new string[](1); - contractNames[0] = "AddressRegistry"; + contractNames[0] = CONTRACT_NAME; LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); - LibRainDeploySnapshot.writeAliasLib(vm, "AddressRegistry", CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); + regenerateLibs(); + } + + /// @notice Rewrite the alias lib and the released-suites lib. Both entry + /// points end here, so there is no entry point that regenerates one and not + /// the other. + function regenerateLibs() internal { + LibRainDeploySnapshot.writeAliasLib(vm, CONTRACT_NAME, CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, "AddressRegistry", candidateSuite().snapshot + vm, LibRainDeploySnapshot.LIB_FS_ROOT, CONTRACT_NAME, candidateSuite().snapshot ); } @@ -83,7 +93,7 @@ contract Build is Script, AddressRegistryDeploySuites { /// this repo currently compiles. function regenerateCandidate() internal { LibRainDeploySnapshot.writeSnapshot( - vm, LibRainDeploySnapshot.CANDIDATE, "AddressRegistry", type(AddressRegistry).creationCode + vm, LibRainDeploySnapshot.CANDIDATE, CONTRACT_NAME, type(AddressRegistry).creationCode ); } } diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index 1c3045d..8c3fe93 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -115,8 +115,13 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { } /// @dev The declaration a generated snapshot records its deploy address in. - /// Matched whole, so what is being looked for is the DECLARATION and cannot - /// be satisfied by characters that happen to occur inside a hex payload. + /// Matched whole and from the START of its line, so what is being looked + /// for is the DECLARATION: it cannot be satisfied by characters that happen + /// to occur inside a hex payload, nor by a line that merely CONTAINS the + /// declaration text — a commented-out copy carrying some other address is + /// exactly the hand edit this whole group exists to catch, and it is at + /// file scope in every generated snapshot, so there is no indentation to + /// allow for. string constant DEPLOYED_ADDRESS_DECLARATION = "address constant DEPLOYED_ADDRESS ="; /// The address a frozen record declares as its deploy address. @@ -140,7 +145,7 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase { function recordedDeployedAddress(string memory path, string memory record) internal pure returns (address) { string[] memory lines = vm.split(record, "\n"); for (uint256 i = 0; i < lines.length; i++) { - if (!vm.contains(lines[i], DEPLOYED_ADDRESS_DECLARATION)) { + if (vm.indexOf(lines[i], DEPLOYED_ADDRESS_DECLARATION) != 0) { continue; } string[] memory tokens = vm.split(lines[i], " "); diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index 59885a7..8a077b2 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -152,6 +152,49 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn assertEq(this.externalRecordedDeployedAddress("record.sol", record), address(0xdead)); } + /// A record in the generated shape whose real declaration is preceded by a + /// COMMENTED OUT one. The commented line is the declaration text verbatim, + /// which is what separates this from a prose mention of an address. + /// @param declared The address the record's real declaration carries. + /// @param commented The address the commented-out declaration carries. + /// @return The record's contents. + function recordCommentingOutADeclaration(address declared, address commented) + internal + pure + returns (string memory) + { + return string.concat( + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + "/// @dev The deterministic deploy address of the contract.\n", + "// address constant DEPLOYED_ADDRESS = address(", + vm.toString(commented), + ");\n", + "address constant DEPLOYED_ADDRESS = address(", + vm.toString(declared), + ");\n" + ); + } + + /// The declaration a record is read from MUST be the one the COMPILER + /// would read. A commented-out declaration is text, and taking it would let + /// a hand edit park a released address above a real declaration carrying + /// something else — the file would then match a released suite while + /// declaring an address no suite derives, which is precisely the hand edit + /// this group exists to catch. The parser cannot be the thing that swallows + /// it. + function testFrozenSnapshotIgnoresACommentedOutDeclaration() external view { + address released = LibRainDeploy.zoltuAddress(releasedSuites()[0].creationCode); + assertEq(released, ADDRESS_REGISTRY_DEPLOYED_ADDRESS); + + string memory record = recordCommentingOutADeclaration(address(0xdead), released); + // The commented-out line really is the declaration text, carrying the + // released address: only its position makes it a comment. + assertTrue(vm.contains(record, string.concat("// ", DEPLOYED_ADDRESS_DECLARATION))); + assertTrue(vm.contains(record, vm.toString(released))); + + assertEq(this.externalRecordedDeployedAddress("record.sol", record), address(0xdead)); + } + /// A file in the record that declares no deploy address at all MUST fail as /// unreadable, naming itself. Reporting it as undeclared would send the /// reader to `releasedSuites()` to add an entry for something that is not a diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index acfd746..d4f0ce2 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -132,21 +132,27 @@ contract LibRainDeploySnapshotTest is Test { string[] memory paths = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FIXTURE_ROOT); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(FIXTURE_ROOT, true); + assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_1/MockDeployable.sol"))); assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/MockDeployableV2.sol"))); assertTrue(holdsPath(paths, string.concat(FIXTURE_ROOT, "/0_0_2/Second.sol"))); assertEq(paths.length, 3); - - //forge-lint: disable-next-line(unsafe-cheatcode) - vm.removeDir(FIXTURE_ROOT, true); } + /// Where the missing-root case reads. Its own tree, for the same reason + /// `RELEASED_FIXTURE_ROOT` is not `FIXTURE_ROOT`: a root another test in + /// this contract builds and tears down is not a root this one can assert is + /// absent, and nothing writes here at all. + string constant MISSING_FIXTURE_ROOT = "test/generated-missing"; + /// A root that is not there at all MUST read as a repo that has released /// nothing, not as a failure. That is the state of every deploy repo before /// its first release, including this one. function testFrozenSnapshotPathsOnAMissingRoot() external view { - assertFalse(vm.exists(FIXTURE_ROOT)); - assertEq(LibRainDeploySnapshot.frozenSnapshotPaths(vm, FIXTURE_ROOT).length, 0); + assertFalse(vm.exists(MISSING_FIXTURE_ROOT)); + assertEq(LibRainDeploySnapshot.frozenSnapshotPaths(vm, MISSING_FIXTURE_ROOT).length, 0); } /// The rolling snapshot MUST NOT be in this repo's own record. It is the @@ -213,12 +219,14 @@ contract LibRainDeploySnapshotTest is Test { string memory written = LibRainDeploySnapshot.writeSnapshot(vm, dir, "MockDeployable", type(MockDeployable).creationCode); - - assertEq(written, LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable")); - assertTrue(vm.exists(written)); + // Read while the snapshot is still there, asserted once it is gone. + bool exists = vm.exists(written); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(LibRainDeploySnapshot.dirForSnapshot(dir), true); + + assertEq(written, LibRainDeploySnapshot.pathForSnapshot(dir, "MockDeployable")); + assertTrue(exists); } /// Where the released-lib record fixture is built. Its own tree rather @@ -516,20 +524,20 @@ contract LibRainDeploySnapshotTest is Test { string[] memory selected = LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT); - assertEq(selected.length, 2); - assertEq(selected[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); - assertEq(selected[1], string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); - // The contract asked for is the contract selected, and the one frozen // beside it has its own single release rather than none. string[] memory second = LibRainDeploySnapshot.recordPathsForContract(vm, SELECTED_FIXTURE_ROOT, FIXTURE_CONTRACT_SECOND); - assertEq(second.length, 1); - assertEq(second[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); - //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(SELECTED_FIXTURE_ROOT, true); + + assertEq(selected.length, 2); + assertEq(selected[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); + assertEq(selected[1], string.concat(SELECTED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); + + assertEq(second.length, 1); + assertEq(second[0], string.concat(SELECTED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT_SECOND, ".sol")); } /// The writer MUST emit the record root it is HANDED, selected down to the @@ -541,8 +549,10 @@ contract LibRainDeploySnapshotTest is Test { /// tag first, so the emitted order is the sort's and not the walk's. /// /// A fixture contract name, so the file written is the fixture's own and - /// not this repo's committed declaration. Removed at the end, because an - /// emitted lib importing a record that only a test wrote does not compile. + /// not this repo's committed declaration. Removed BEFORE the assertions, + /// because an emitted lib importing a record that only a test wrote does + /// not compile, and forge-std assertions revert — undoing afterwards is + /// undoing in every case except the one this test exists to report. function testWriteReleasedSuitesLibReadsTheRecordItIsHanded() external { writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_10_0/", FIXTURE_CONTRACT, ".sol")); writeFixture(string.concat(RELEASED_FIXTURE_ROOT, "/0_9_0/", FIXTURE_CONTRACT, ".sol")); @@ -561,34 +571,31 @@ contract LibRainDeploySnapshotTest is Test { string memory libraryName = string.concat("Lib", FIXTURE_CONTRACT, "Released"); string memory path = string.concat("src/lib/", libraryName, ".sol"); - assertEq( - LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate() - ), - path - ); + string memory written = + LibRainDeploySnapshot.writeReleasedSuitesLib(vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate()); string memory emitted = vm.readFile(path); - assertEq( - emitted, - string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", - "pragma solidity ^0.8.25;\n\n", - "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", - LibRainDeploySnapshot.releasedImportBlock(vm, paths), - LibRainDeploySnapshot.releasedLibraryBlock(vm, libraryName, FIXTURE_CONTRACT, paths, emitterTemplate()) - ) + // Built while the fixture record is still there: both emitters read it. + string memory expected = string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, libraryName, FIXTURE_CONTRACT, paths, emitterTemplate()) ); - assertFalse(vm.contains(emitted, FIXTURE_CONTRACT_SECOND)); - assertFalse(vm.contains(emitted, LibRainDeploySnapshot.CANDIDATE)); - assertFalse(vm.contains(emitted, "collision-guard")); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeFile(path); //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(RELEASED_FIXTURE_ROOT, true); + + assertEq(written, path); + assertEq(emitted, expected); + assertFalse(vm.contains(emitted, FIXTURE_CONTRACT_SECOND)); + assertFalse(vm.contains(emitted, LibRainDeploySnapshot.CANDIDATE)); + assertFalse(vm.contains(emitted, "collision-guard")); } /// The released lib MUST land beside the alias lib, under the name derived @@ -598,38 +605,35 @@ contract LibRainDeploySnapshotTest is Test { /// Run against this repo's REAL record and its real contract, so what it /// writes is the committed generated file — that is the whole of what makes /// a stale generated file a test failure rather than a silent one. Restored - /// afterwards, because a run that failed between the write and the - /// assertion would otherwise leave the tree dirty. + /// BEFORE the assertions run, because forge-std assertions revert: restoring + /// afterwards restores in every case except a failure, which is the only + /// case where the tree is dirty and the one this test exists to report. function testWriteReleasedSuitesLibWritesTheLibAtItsPath() external { string memory path = "src/lib/LibAddressRegistryReleased.sol"; string memory before = vm.readFile(path); - assertEq( - LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT, emitterTemplate() - ), - path + string memory written = LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT, emitterTemplate() ); + string memory emitted = vm.readFile(path); string[] memory paths = LibRainDeploySnapshot.recordPathsForContract(vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT); - assertEq( - vm.readFile(path), - string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", - "pragma solidity ^0.8.25;\n\n", - "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", - LibRainDeploySnapshot.releasedImportBlock(vm, paths), - LibRainDeploySnapshot.releasedLibraryBlock( - vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate() - ) - ) + string memory expected = string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + LibRainDeploySnapshot.releasedImportBlock(vm, paths), + LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate()) ); //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile(path, before); + + assertEq(written, path); + assertEq(emitted, expected); } /// A freeze that names no contracts MUST be refused. It would write From 696f2e59841f36235bb001a985e2d509e1abbce0 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 13:22:16 +0000 Subject: [PATCH 28/29] style(test): wrap the writer call forge fmt wraps Co-Authored-By: Claude Opus 5 (1M context) --- test/src/lib/LibRainDeploySnapshot.t.sol | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index d4f0ce2..ddf4d65 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -571,8 +571,9 @@ contract LibRainDeploySnapshotTest is Test { string memory libraryName = string.concat("Lib", FIXTURE_CONTRACT, "Released"); string memory path = string.concat("src/lib/", libraryName, ".sol"); - string memory written = - LibRainDeploySnapshot.writeReleasedSuitesLib(vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate()); + string memory written = LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, RELEASED_FIXTURE_ROOT, FIXTURE_CONTRACT, emitterTemplate() + ); string memory emitted = vm.readFile(path); // Built while the fixture record is still there: both emitters read it. From 57618df6ec05422461b116f961a72e1ab7288e8f Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 14:14:43 +0000 Subject: [PATCH 29/29] Review fixes: slither scope, address decode, and doc claims that were false - checkResolvedAddresses decoded with abi.decode(_, (address)), which reverts with empty return data on dirty upper bits, so a one-word answer that is not an address produced a bare revert instead of ResolvedAddressReadFailed. Decode as a word, range check, then narrow. Covered by testCheckResolvedAddressesDirtyWordReverts. - low-level-calls was excluded repo-wide for one deliberate staticcall. Excluded at the site instead, so the detector stays live everywhere else. - README claimed an unreachable RPC endpoint fails only the chain contract, and that the chain group has no exemption. Both false: 26 fork tests in LibRainDeployTest fail without RPCs, and the chain group is released-only. - README listed three verification groups; there are four. Added the record-anchored row. - README justified the forge-std requirement by claiming everything under src/ is test-and-script infrastructure. Four of fourteen files import forge-std. The requirement is transitive through the inherited abstracts. - README's Publish section named a v tag and a workflow that does not exist, contradicting the sol-v* lifecycle documented above it. - README and CLAUDE.md named rainix-sol-{test,static,legal} as commands. Those are reusable workflow names and no longer exist in rainix; documented what each one runs. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 16 +++++---- README.md | 53 +++++++++++++++------------- slither.config.json | 2 +- src/lib/LibRainDeploy.sol | 17 ++++++++- test/concrete/MockDirtyWordOwner.sol | 26 ++++++++++++++ test/src/lib/LibRainDeploy.t.sol | 33 +++++++++++++++++ 6 files changed, 115 insertions(+), 32 deletions(-) create mode 100644 test/concrete/MockDirtyWordOwner.sol diff --git a/CLAUDE.md b/CLAUDE.md index 11e6d1e..b67a857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,22 +28,26 @@ nix develop nix develop -c forge build # Run tests -nix develop -c rainix-sol-test +nix develop -c forge test -vvv # Run a single test nix develop -c forge test --match-test "testName" # Static analysis / linting -nix develop -c rainix-sol-static +nix develop -c slither . +nix develop -c forge fmt --check +nix develop -c rainix-sol-single-contract # License/legal checks (REUSE compliance) -nix develop -c rainix-sol-legal +nix develop -c reuse lint ``` CI runs three matrix tasks: `rainix-sol-legal`, `rainix-sol-test`, -`rainix-sol-static`. There is a fourth workflow, `Manual sol artifacts`, which -is `workflow_dispatch` only and is the on-chain deploy — nothing automatic ever -broadcasts. +`rainix-sol-static`. Those are rainix reusable workflow names, not commands — +the block above is what they run. + +A fourth workflow, `Manual sol artifacts`, is `workflow_dispatch` only and is +the on-chain deploy — nothing automatic ever broadcasts. ## RPC Configuration diff --git a/README.md b/README.md index 84b24b4..7f03e52 100644 --- a/README.md +++ b/README.md @@ -90,14 +90,15 @@ Deriving the pins at broadcast time would make that comparison derived-against-derived, and a guard that compares a value to itself is not a guard. -Three groups, sorted by what each is anchored to and therefore by what each can +Four groups, sorted by what each is anchored to and therefore by what each can catch: | Group | Anchored to | Catches | Cannot catch | | -------- | ---------------------- | ------------------------------------- | -------------------------------- | | Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract | | Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain | -| Chain | the networks | never deployed, or not there any more | anything before it is deployed | +| Record | the frozen record | a release the declaration missed | what a declared suite records | +| Chain | the networks | never deployed, or not there any more | anything about the candidate | The internal group's blind spot is not a gap to close there: every check in it asks the recorded bytes to agree with each other, and the wrong contract's bytes @@ -107,14 +108,21 @@ to have diverged from current source, so anchoring one to source asserts something false by design. That is a property of the assertion, and there is no field on a released version with which to opt in or out. -The chain group has no such exemption. It applies to every version, including -one that has never been deployed — where it fails, and that failure is the -answer. - -It is a separate contract so that an unreachable RPC endpoint fails only it. -`forge test --no-match-contract Chain` is the whole snapshot gate, and it is -structural rather than conventional: nothing reachable from the snapshot -contracts forks anything. +The chain group carries the mirror image of that exemption: it applies to +**released versions only**. A release IS a deployment that happened, so "it is +live on every supported network" is either true of it or a defect. The candidate +is what the next release will be, ordinarily ahead of anything on chain, so +demanding it be live asserts something false by design in the other direction. +Neither exemption is a field a caller can set. + +Scoping to releases puts the whole weight on `releasedSuites()` naming every +release, which is what the record group is for. A frozen tag the declaration +misses is not an entry that turns up missing somewhere — it is a release the +chain group is never handed, and a check with no subject cannot fail on it, so +that release drops out of everything while the suite stays green. The +declaration is generated from the append-only `src/generated//` record and +checked back against it, matched by address, since matching by name would assert +only that a convention was followed. **Chain-independent runtime code is a requirement, not a caveat.** One recorded code hash per version can only be true if the runtime code is the same @@ -197,9 +205,11 @@ forge soldeer install rain-deploy~ **You also need `forge-std` 1.16.1**, remapped as `forge-std-1.16.1/`. The published package deliberately ships only `src/` and `script/` — no -`remappings.txt`, no `soldeer.lock`, no `dependencies/` — and everything under -`src/` here is Foundry test-and-script infrastructure that imports `Vm`, -`console2` or `Test`. So a consumer resolves `forge-std` itself: +`remappings.txt`, no `soldeer.lock`, no `dependencies/` — so a consumer resolves +`forge-std` itself. The requirement is transitive rather than incidental: the +deployed contract imports nothing outside this package, but every abstract a +consumer inherits pulls forge-std in — `Script` via `RainDeployBroadcast`, +`Test` via `RainDeployVerifyBase`, and `Vm` via `LibRainDeploy` beneath both: ```toml [dependencies] @@ -222,21 +232,16 @@ forge soldeer install # install deps declared in foundry.toml forge test ``` -Tasks: +The three CI jobs are rainix reusable workflows, not commands in the shell. What +each of them runs, which is what reproduces it locally: -- `rainix-sol-test` — `forge test` -- `rainix-sol-static` — slither +- `rainix-sol-test` — `forge test -vvv` - `rainix-sol-legal` — `reuse lint` +- `rainix-sol-static` — `slither .`, `forge fmt --check`, then + `rainix-sol-single-contract` Use the nix-pinned `forge` for all development. -## Publish - -Tag `v` on `main`. The -[`Publish to Soldeer`](.github/workflows/publish-soldeer.yaml) wrapper delegates -to rainix's reusable workflow, which derives the package name from the repo name -(`rain.deploy` → `rain-deploy`). - ## License DecentraLicense 1.0 (DCL-1.0) — full text in @@ -248,7 +253,7 @@ This repo is [REUSE 3.2](https://reuse.software/spec-3.2/) compliant. Verify locally: ```sh -nix develop -c rainix-sol-legal +nix develop -c reuse lint ``` ## Contributions diff --git a/slither.config.json b/slither.config.json index f01fbea..acb8796 100644 --- a/slither.config.json +++ b/slither.config.json @@ -1,4 +1,4 @@ { "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|AddressRegistryDeploySuites)\\.sol", - "detectors_to_exclude": "assembly,low-level-calls" + "detectors_to_exclude": "assembly" } diff --git a/src/lib/LibRainDeploy.sol b/src/lib/LibRainDeploy.sol index 6180b2b..f5769b5 100644 --- a/src/lib/LibRainDeploy.sol +++ b/src/lib/LibRainDeploy.sol @@ -245,6 +245,11 @@ library LibRainDeploy { revert ResolvedAddressesLengthMismatch(readCalls.length, expectedAddresses.length); } for (uint256 i = 0; i < readCalls.length; i++) { + // The consumer supplies the reads, so the call is low level by + // construction: there is no interface here to call through. Excluded + // at the site rather than repo-wide so a low-level call added + // anywhere else is still reported. + // slither-disable-next-line low-level-calls (bool success, bytes memory returnData) = target.staticcall(readCalls[i]); // A read that reverts, answers nothing (no code at `target`), or // answers something that is not one word cannot be compared, and is @@ -252,7 +257,17 @@ library LibRainDeploy { if (!success || returnData.length != 0x20) { revert ResolvedAddressReadFailed(network, target, i, returnData); } - address actual = abi.decode(returnData, (address)); + // Decoded as a word and range checked here rather than decoded as an + // address, because `abi.decode(_, (address))` reverts with no data + // of its own when the word's upper 96 bits are dirty. A read that + // answers with a word that is not an address is exactly the case + // `ResolvedAddressReadFailed` is for, so it is reported as that + // rather than as a bare revert nothing can diagnose. + uint256 word = abi.decode(returnData, (uint256)); + if (word > type(uint160).max) { + revert ResolvedAddressReadFailed(network, target, i, returnData); + } + address actual = address(uint160(word)); if (actual != expectedAddresses[i]) { revert UnexpectedResolvedAddress(network, target, i, expectedAddresses[i], actual); } diff --git a/test/concrete/MockDirtyWordOwner.sol b/test/concrete/MockDirtyWordOwner.sol new file mode 100644 index 0000000..68d6c54 --- /dev/null +++ b/test/concrete/MockDirtyWordOwner.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +/// @title MockDirtyWordOwner +/// @notice Answers the same read `MockResolvedOwner` answers, but with an +/// arbitrary 32-byte word rather than an address. Nothing on the wire +/// distinguishes the two until the upper 96 bits are looked at, and contracts +/// that answer a read with a word rather than an address are ordinary: a getter +/// whose declared return type is `bytes32` or `uint256`, or one written in +/// assembly, hands back whatever word it holds. +contract MockDirtyWordOwner { + /// The word this contract answers every read with. + bytes32 public immutable iWord; + + /// @param word The word to answer with. + constructor(bytes32 word) { + iWord = word; + } + + /// The selector `MockResolvedOwner` answers, returning a raw word. + /// @return The word. + function iOwner() external view returns (bytes32) { + return iWord; + } +} diff --git a/test/src/lib/LibRainDeploy.t.sol b/test/src/lib/LibRainDeploy.t.sol index 461fd44..ecb81ec 100644 --- a/test/src/lib/LibRainDeploy.t.sol +++ b/test/src/lib/LibRainDeploy.t.sol @@ -7,6 +7,7 @@ import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {IAddressRegistryV1} from "../../../src/interface/IAddressRegistryV1.sol"; import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; import {MockResolvedOwner} from "../../concrete/MockResolvedOwner.sol"; +import {MockDirtyWordOwner} from "../../concrete/MockDirtyWordOwner.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; import {MockDeployableV2} from "../../concrete/MockDeployableV2.sol"; import {MockReverter} from "../../concrete/MockReverter.sol"; @@ -831,6 +832,38 @@ contract LibRainDeployTest is Test { this.externalCheckResolvedAddresses("test_network", address(consumer), readCalls, expected(account)); } + /// A read that answers with one word whose upper 96 bits are dirty has not + /// answered with an address, and MUST be reported as + /// `ResolvedAddressReadFailed` — the error whose stated subject is a read + /// that answers with something that is not a single address-sized word. + /// + /// The expected address here is the word's own low 160 bits, so the only + /// thing wrong with the answer is the dirty bits. That rules out both ways + /// of getting this wrong at once: truncating the word silently PASSES this + /// check against an address the read never gave, and decoding it as an + /// address reverts inside the decoder with no return data at all — a bare + /// revert naming neither the network, the target, nor which read produced + /// it, which is exactly what this error exists to avoid. + function testCheckResolvedAddressesDirtyWordReverts(bytes32 word) external { + // Only the words that are not an address. A clean one is an address and + // decodes, which is `testCheckResolvedAddressesMatch`'s case. + vm.assume(uint256(word) > type(uint160).max); + MockDirtyWordOwner target = new MockDirtyWordOwner(word); + + vm.expectRevert( + abi.encodeWithSelector( + LibRainDeploy.ResolvedAddressReadFailed.selector, + "test_network", + address(target), + uint256(0), + abi.encode(word) + ) + ); + this.externalCheckResolvedAddresses( + "test_network", address(target), ownerReadCalls(), expected(address(uint160(uint256(word)))) + ); + } + /// `checkResolvedAddresses` MUST revert when the reads and expected /// addresses do not pair up, rather than checking the shorter of the two. function testCheckResolvedAddressesLengthMismatchReverts(uint8 readCallsLength, uint8 expectedLength) external {