From 27a1f8d97dc4541490f22dc924728bf04d9d7bf3 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 16:14:16 +0000 Subject: [PATCH 01/11] wip(suites): pluralize the candidate side of RainDeploySuitesBase Incomplete handoff commit from an agent that hit the context limit before compiling. Not verified: does not build, no tests run. Co-Authored-By: Claude Opus 5 (1M context) --- script/Build.sol | 15 ++-- src/abstract/AddressRegistryDeploySuites.sol | 26 ++++++- src/abstract/RainDeploySuitesBase.sol | 74 ++++++++++++++++--- src/abstract/RainDeployVerifySnapshot.sol | 28 ++++++- test/abstract/ExampleDeploySuites.sol | 43 ++++++++--- test/abstract/ExternalDeploySuites.sol | 36 +++++++++ .../CollidingCandidateDeploySuites.sol | 63 ++++++++++++++++ test/concrete/DuplicateDeploySuites.sol | 20 ++--- test/concrete/ExampleDeploy.sol | 20 +---- test/concrete/NoCandidateDeploySuites.sol | 41 ++++++++++ test/src/abstract/RainDeploySuitesBase.t.sol | 73 ++++++++++++++++-- .../RainDeployVerifyChainCandidate.t.sol | 5 +- .../abstract/RainDeployVerifySnapshot.t.sol | 41 +++++++++- 13 files changed, 410 insertions(+), 75 deletions(-) create mode 100644 test/abstract/ExternalDeploySuites.sol create mode 100644 test/concrete/CollidingCandidateDeploySuites.sol create mode 100644 test/concrete/NoCandidateDeploySuites.sol diff --git a/script/Build.sol b/script/Build.sol index 947b655..74e3951 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -32,10 +32,15 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// 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. +/// from the named candidate on the declaration, which is why this inherits the +/// declaration rather than restating it. There is one suite key, one artifact +/// path and one dependency list per contract in this repo, and a second copy of +/// them here is a second copy that drifts. +/// +/// Named rather than indexed out of `candidateSuites()`: a released-suites lib +/// describes ONE contract, so this has to select the candidate for the contract +/// it is writing, and a positional read would silently write another contract's +/// metadata the moment the list is reordered. /// /// The tag, both snapshot paths, the freeze, the snapshot writer and both /// generated-lib writers all come from `LibRainDeploySnapshot`, which in turn @@ -85,7 +90,7 @@ contract Build is Script, AddressRegistryDeploySuites { function regenerateLibs() internal { LibRainDeploySnapshot.writeAliasLib(vm, CONTRACT_NAME, CONSTANT_PREFIX, LibRainDeploySnapshot.CANDIDATE); LibRainDeploySnapshot.writeReleasedSuitesLib( - vm, LibRainDeploySnapshot.LIB_FS_ROOT, CONTRACT_NAME, candidateSuite().snapshot + vm, LibRainDeploySnapshot.LIB_FS_ROOT, CONTRACT_NAME, addressRegistryCandidate().snapshot ); } diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/AddressRegistryDeploySuites.sol index bbe3e41..2737433 100644 --- a/src/abstract/AddressRegistryDeploySuites.sol +++ b/src/abstract/AddressRegistryDeploySuites.sol @@ -54,9 +54,19 @@ abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { } /// @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. + /// @dev One entry, because this repo deploys one contract. A second + /// deployed contract is a second named candidate below, a second entry + /// here, and a second snapshot in `script/Build.sol` — nothing else. + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](1); + candidates[0] = addressRegistryCandidate(); + } + + /// This repo's rolling `AddressRegistry` candidate. + /// + /// 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 are RECORDED, read from the rolling /// `src/generated/candidate/` snapshot. That is what makes the source @@ -67,7 +77,15 @@ abstract contract AddressRegistryDeploySuites is RainDeploySuitesBase { /// /// `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) { + /// + /// Named rather than reached by index into `candidateSuites`, because + /// `script/Build.sol` needs THIS candidate specifically — a released-suites + /// lib describes one contract — and a positional read silently describes a + /// different contract the moment the list is reordered. Naming it here also + /// keeps the suite key, the artifact path and the dependency list spelled + /// once: `Build.sol` inherits this rather than restating them. + /// @return The candidate. + function addressRegistryCandidate() internal pure returns (DeployCandidate memory) { return DeployCandidate({ snapshot: DeploySuite({ suite: "address-registry", diff --git a/src/abstract/RainDeploySuitesBase.sol b/src/abstract/RainDeploySuitesBase.sol index 44bc2fe..24785f5 100644 --- a/src/abstract/RainDeploySuitesBase.sol +++ b/src/abstract/RainDeploySuitesBase.sol @@ -14,6 +14,23 @@ error DuplicateDeploySuite(string suite); /// @param validSuites The declared keys, comma separated. error UnknownDeploymentSuite(string requested, string validSuites); +/// Thrown when a declaration names no candidate at all. +/// +/// The source anchor is the ONLY check that catches a snapshot of the wrong +/// contract, and it runs over the candidates and nothing else. A declaration +/// with an empty candidate list is therefore not a repo with nothing to say — +/// it is a declaration that has quietly opted out of that check while every +/// other assertion stays green. +/// +/// A deploy repo always compiles a current source, so there is always something +/// to declare. When the candidate was a single struct this was true by +/// construction; a list has to say it. +/// +/// Raised from `allSuites`, which is the only way anything reads the +/// declaration — `suiteNames` and `suiteByName` both go through it — so there +/// is no reader that answers from an empty one. +error NoDeployCandidates(); + /// One deployable unit: a named snapshot of one contract. /// /// `creationCode` is the ONLY input. The Zoltu factory is `CREATE2` over its @@ -98,7 +115,7 @@ struct DeployCandidate { /// 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 +/// A repo overrides `releasedSuites` and `candidateSuites` 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 { @@ -109,28 +126,63 @@ abstract contract RainDeploySuitesBase { /// @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); + /// The rolling candidates — one snapshot per contract this repo compiles + /// right now, each paired with the source it MUST equal. + /// + /// A list because a repo deploys as many contracts as it deploys, and each + /// of them has its own rolling snapshot and its own source to be anchored + /// to. A single candidate leaves a repo's second deployed contract either + /// undeclared or declared as a release it is not, and in both cases the one + /// check that catches a snapshot of the wrong contract is never handed it. + /// + /// Two suites abstracts cannot be composed into that gap either: both would + /// override this, and a repo inherits exactly one declaration. So the list + /// is here rather than left to the consumer to assemble. + /// + /// MUST NOT be empty, which `allSuites` enforces. A deploy repo always + /// compiles a current source, so there is always something to anchor to — + /// see `NoDeployCandidates` for why an empty list is worse than it looks. + /// @return The candidates. + function candidateSuites() internal pure virtual returns (DeployCandidate[] memory); + + /// The declared candidates, refusing an empty list. + /// + /// The ONE place `NoDeployCandidates` is raised, and the only way anything + /// reads the candidates. `allSuites` goes through it, and so does the + /// source anchor in `RainDeployVerifySnapshot` — which matters, because the + /// source anchor loops over the candidates and a loop over an empty list + /// passes. Guarding each reader separately would be two spellings of one + /// rule, and the reader that got the second spelling wrong is the one that + /// silently stops asserting. + /// @return candidates The candidates. + function checkedCandidateSuites() internal pure returns (DeployCandidate[] memory candidates) { + candidates = candidateSuites(); + if (candidates.length == 0) { + revert NoDeployCandidates(); + } + } /// Every suite this repo declares: the released ones followed by the - /// candidate. This is the verification set and the deploy registry, which + /// candidates. 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. + /// One pairwise pass over the whole set, so a candidate colliding with + /// another candidate is caught by the same code that catches a candidate + /// colliding with a release — there is no second rule to keep in step. /// @return suites Every declared suite. function allSuites() internal pure returns (DeploySuite[] memory suites) { DeploySuite[] memory released = releasedSuites(); - suites = new DeploySuite[](released.length + 1); + DeployCandidate[] memory candidates = checkedCandidateSuites(); + + suites = new DeploySuite[](released.length + candidates.length); for (uint256 i = 0; i < released.length; i++) { suites[i] = released[i]; } - suites[released.length] = candidateSuite().snapshot; + for (uint256 i = 0; i < candidates.length; i++) { + suites[released.length + i] = candidates[i].snapshot; + } for (uint256 i = 0; i < suites.length; i++) { for (uint256 j = i + 1; j < suites.length; j++) { diff --git a/src/abstract/RainDeployVerifySnapshot.sol b/src/abstract/RainDeployVerifySnapshot.sol index 8c3fe93..05416d7 100644 --- a/src/abstract/RainDeployVerifySnapshot.sol +++ b/src/abstract/RainDeployVerifySnapshot.sol @@ -229,10 +229,32 @@ abstract contract RainDeployVerifySnapshot 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. + /// Checks every candidate in a set against the source it claims to be. + /// + /// Every one, because this is the only check that catches a wrong-contract + /// snapshot at all: a candidate the loop never reaches is a contract whose + /// snapshot nothing anywhere anchors, and a repo with several contracts is + /// exactly where a snapshot generated from the wrong one comes from. + /// + /// Takes the set as an argument, as `checkFrozenSnapshotsReleased` does, so + /// the loop is drivable with a set built to break it rather than only with + /// whatever the inheriting repo happens to declare. + /// @param candidates The candidates to check. + function checkCandidatesAnchoredToSource(DeployCandidate[] memory candidates) internal pure { + for (uint256 i = 0; i < candidates.length; i++) { + checkAnchoredToSource(candidates[i]); + } + } + + /// EVERY candidate MUST be a snapshot of the contract this repo compiles, + /// not of some other contract that happens to be internally consistent. + /// + /// Read through `checkedCandidateSuites` rather than `candidateSuites`: a + /// loop over an empty list passes, so a declaration with no candidate at + /// all would turn the one check that catches a wrong-contract snapshot into + /// a green test that asserts nothing. function testSnapshotMatchesSource() external pure { - checkAnchoredToSource(candidateSuite()); + checkCandidatesAnchoredToSource(checkedCandidateSuites()); } /// Every release in the frozen record MUST be declared, so that the set the diff --git a/test/abstract/ExampleDeploySuites.sol b/test/abstract/ExampleDeploySuites.sol index 759e50b..d859ecc 100644 --- a/test/abstract/ExampleDeploySuites.sol +++ b/test/abstract/ExampleDeploySuites.sol @@ -23,13 +23,19 @@ import {MockDeployableV2} from "../concrete/MockDeployableV2.sol"; /// 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. +/// The `MockDeployableV2` entries exist for one reason: every loop here runs +/// over a list, and proving a loop does not stop at the first entry requires a +/// second entry 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. +/// +/// It appears on BOTH sides for that reason, once as a release and once as a +/// candidate. The released side is the chain matrix's second address; the +/// candidate side is the source anchor's, which loops over the candidates +/// alone and would otherwise be a loop with one thing in it. abstract contract ExampleDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { @@ -55,8 +61,15 @@ abstract contract ExampleDeploySuites is RainDeploySuitesBase { } /// @inheritdoc RainDeploySuitesBase - function candidateSuite() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ + /// @dev TWO candidates, at two different addresses and anchored to two + /// different sources, because a repo that deploys more than one contract is + /// what the list exists for. One candidate cannot tell a loop that runs + /// over every candidate apart from one that stops at the first, and the + /// source anchor is the only check that catches a snapshot of the wrong + /// contract — so a loop that stops early is a contract nothing anchors. + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](2); + candidates[0] = DeployCandidate({ snapshot: DeploySuite({ suite: "address-registry-candidate", creationCode: ADDRESS_REGISTRY_CREATION_CODE, @@ -68,5 +81,17 @@ abstract contract ExampleDeploySuites is RainDeploySuitesBase { }), sourceCreationCode: type(AddressRegistry).creationCode }); + candidates[1] = 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 + }); } } diff --git a/test/abstract/ExternalDeploySuites.sol b/test/abstract/ExternalDeploySuites.sol new file mode 100644 index 0000000..31c5f7f --- /dev/null +++ b/test/abstract/ExternalDeploySuites.sol @@ -0,0 +1,36 @@ +// 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"; + +/// @title ExternalDeploySuites +/// @notice The three registry reads, exposed externally so a plain `Test` +/// contract can drive them and `vm.expectRevert` lands at the right call depth. +/// +/// Here rather than on each fixture because every fixture needs the same three, +/// and a declaration that is refused has to be refused on ALL of them — a +/// wrapper a fixture forgot to carry is a reader nothing checks that fixture +/// through. +abstract contract ExternalDeploySuites is RainDeploySuitesBase { + /// @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); + } + + /// @return The declared keys, comma separated. + function externalSuiteNames() external pure returns (string memory) { + return suiteNames(); + } + + /// @return The declared candidates, refusing an empty list. + function externalCheckedCandidateSuites() external pure returns (DeployCandidate[] memory) { + return checkedCandidateSuites(); + } +} diff --git a/test/concrete/CollidingCandidateDeploySuites.sol b/test/concrete/CollidingCandidateDeploySuites.sol new file mode 100644 index 0000000..cd84176 --- /dev/null +++ b/test/concrete/CollidingCandidateDeploySuites.sol @@ -0,0 +1,63 @@ +// 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 {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; +import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; +import {MockDeployableV2} from "./MockDeployableV2.sol"; +import {LibRainDeploy} from "../../src/lib/LibRainDeploy.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 CollidingCandidateDeploySuites +/// A declaration with no releases at all and TWO candidates under one key. +/// +/// Distinct from `DuplicateDeploySuites`, which collides a release with a +/// candidate. A key that selects what gets broadcast has to be unique across +/// the whole registry, and a candidate-against-candidate collision is the case +/// that only exists once the candidate side is a list — it is unreachable while +/// a repo can declare only one. +/// +/// The two candidates are DIFFERENT contracts at different addresses, so the +/// key is the only thing they share: a check that compared anything else would +/// let this through. +contract CollidingCandidateDeploySuites is ExternalDeploySuites { + /// @inheritdoc RainDeploySuitesBase + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + suites = new DeploySuite[](0); + } + + /// @inheritdoc RainDeploySuitesBase + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](2); + candidates[0] = DeployCandidate({ + snapshot: 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) + }), + sourceCreationCode: type(AddressRegistry).creationCode + }); + candidates[1] = DeployCandidate({ + snapshot: DeploySuite({ + suite: "collides", + 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 + }); + } +} diff --git a/test/concrete/DuplicateDeploySuites.sol b/test/concrete/DuplicateDeploySuites.sol index a4185a0..e2c78bc 100644 --- a/test/concrete/DuplicateDeploySuites.sol +++ b/test/concrete/DuplicateDeploySuites.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; import {AddressRegistry} from "../../src/concrete/AddressRegistry.sol"; import { BYTECODE_HASH as ADDRESS_REGISTRY_BYTECODE_HASH, @@ -14,7 +15,7 @@ import { /// @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 { +contract DuplicateDeploySuites is ExternalDeploySuites { /// The suite both entries declare, identically. /// @return The colliding suite. function collidingSuite() internal pure returns (DeploySuite memory) { @@ -36,18 +37,9 @@ contract DuplicateDeploySuites is RainDeploySuitesBase { } /// @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); + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](1); + candidates[0] = + DeployCandidate({snapshot: collidingSuite(), sourceCreationCode: type(AddressRegistry).creationCode}); } } diff --git a/test/concrete/ExampleDeploy.sol b/test/concrete/ExampleDeploy.sol index 0d2fc0b..ae9b70e 100644 --- a/test/concrete/ExampleDeploy.sol +++ b/test/concrete/ExampleDeploy.sol @@ -2,32 +2,16 @@ // 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 {ExampleDeploySuites} from "../abstract/ExampleDeploySuites.sol"; +import {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.sol"; /// @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) { - 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(); - } - +contract ExampleDeploy is ExampleDeploySuites, ExternalDeploySuites, RainDeployBroadcast { /// @return The networks a broadcast would go to. function externalDeployNetworks() external view returns (string[] memory) { return deployNetworks(); diff --git a/test/concrete/NoCandidateDeploySuites.sol b/test/concrete/NoCandidateDeploySuites.sol new file mode 100644 index 0000000..df1de45 --- /dev/null +++ b/test/concrete/NoCandidateDeploySuites.sol @@ -0,0 +1,41 @@ +// 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 {ExternalDeploySuites} from "../abstract/ExternalDeploySuites.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 NoCandidateDeploySuites +/// A declaration with releases and NO candidate — the shape that looks like a +/// repo with nothing left to say and is actually a repo that has opted out of +/// the only check catching a snapshot of the wrong contract. +/// +/// It declares a release deliberately, so the refusal cannot be passing for the +/// trivial reason that there is nothing declared at all. Everything except the +/// candidate is present and correct. +contract NoCandidateDeploySuites is ExternalDeploySuites { + /// @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 candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](0); + } +} diff --git a/test/src/abstract/RainDeploySuitesBase.t.sol b/test/src/abstract/RainDeploySuitesBase.t.sol index c3da531..5a619ff 100644 --- a/test/src/abstract/RainDeploySuitesBase.t.sol +++ b/test/src/abstract/RainDeploySuitesBase.t.sol @@ -7,10 +7,13 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import { DeploySuite, DuplicateDeploySuite, + NoDeployCandidates, UnknownDeploymentSuite } from "../../../src/abstract/RainDeploySuitesBase.sol"; import {ExampleDeploy} from "../../concrete/ExampleDeploy.sol"; +import {CollidingCandidateDeploySuites} from "../../concrete/CollidingCandidateDeploySuites.sol"; import {DuplicateDeploySuites} from "../../concrete/DuplicateDeploySuites.sol"; +import {NoCandidateDeploySuites} from "../../concrete/NoCandidateDeploySuites.sol"; /// @title RainDeploySuitesBaseTest /// @notice The registry itself: one declaration, keyed lookup, and the two ways @@ -29,17 +32,18 @@ contract RainDeploySuitesBaseTest is Test { sSuites = new ExampleDeploy(); } - /// The registry MUST be the released suites followed by the candidate, in + /// The registry MUST be the released suites followed by the candidates, 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 { + function testAllSuitesIsReleasedThenCandidates() external view { DeploySuite[] memory suites = sSuites.externalAllSuites(); - assertEq(suites.length, 3); + assertEq(suites.length, 4); assertEq(suites[0].suite, "address-registry-0-0-1"); assertEq(suites[1].suite, "second-address"); assertEq(suites[2].suite, "address-registry-candidate"); + assertEq(suites[3].suite, "second-address-candidate"); } /// Every declared key MUST select its own suite. A deploy is dispatched per @@ -78,7 +82,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "mock-deployable", - "address-registry-0-0-1, second-address, address-registry-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" ) ); sSuites.externalSuiteByName("mock-deployable"); @@ -91,7 +95,7 @@ contract RainDeploySuitesBaseTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "address-registry-0-0-1, second-address, address-registry-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" ) ); sSuites.externalSuiteByName(""); @@ -99,7 +103,10 @@ contract RainDeploySuitesBaseTest is Test { /// The reported key list MUST be exactly the registry, in order. function testSuiteNamesIsTheRegistry() external view { - assertEq(sSuites.externalSuiteNames(), "address-registry-0-0-1, second-address, address-registry-candidate"); + assertEq( + sSuites.externalSuiteNames(), + "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" + ); } /// Two suites under one key MUST fail, on BOTH paths that read the @@ -115,4 +122,58 @@ contract RainDeploySuitesBaseTest is Test { vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); duplicates.externalSuiteByName("collides"); } + + /// Two CANDIDATES under one key MUST fail exactly as a release colliding + /// with a candidate does. The uniqueness rule is about the registry, not + /// about which side of it an entry came from, and this is the collision + /// that only becomes representable once the candidate side is a list. + function testDuplicateCandidateKeyReverts() external { + CollidingCandidateDeploySuites candidates = new CollidingCandidateDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + candidates.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + candidates.externalSuiteByName("collides"); + + vm.expectRevert(abi.encodeWithSelector(DuplicateDeploySuite.selector, "collides")); + candidates.externalSuiteNames(); + } + + /// A declaration with NO candidate MUST be refused on every read. + /// + /// An empty candidate list reads as a repo with nothing left to declare and + /// is a repo whose source anchor — the only check that catches a snapshot + /// of the wrong contract — has been handed nothing to run over. It is + /// refused rather than tolerated, and refused on all three readers, because + /// a reader that answers from an empty declaration is a reader through + /// which the whole registry can be empty and green. + function testNoCandidateReverts() external { + NoCandidateDeploySuites none = new NoCandidateDeploySuites(); + + vm.expectRevert(abi.encodeWithSelector(NoDeployCandidates.selector)); + none.externalAllSuites(); + + vm.expectRevert(abi.encodeWithSelector(NoDeployCandidates.selector)); + none.externalSuiteNames(); + + // Refused BEFORE the key is even looked for: an empty declaration + // cannot answer "no such suite" either, because it has no valid set to + // report and the answer would send the reader after a typo. + vm.expectRevert(abi.encodeWithSelector(NoDeployCandidates.selector)); + none.externalSuiteByName("address-registry-0-0-1"); + + // And at the source of the refusal itself, which is what the + // source-anchored check reads through — a loop over an empty list + // passes, so that check cannot be the thing that catches this. + vm.expectRevert(abi.encodeWithSelector(NoDeployCandidates.selector)); + none.externalCheckedCandidateSuites(); + } + + /// The refusal MUST be discriminating: a declaration that DOES name a + /// candidate answers all four readers rather than reverting, so the test + /// above is about emptiness and not about the fixture. + function testCandidatesPresentAnswers() external view { + assertEq(sSuites.externalCheckedCandidateSuites().length, 2); + } } diff --git a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol index f3e1894..94f42e8 100644 --- a/test/src/abstract/RainDeployVerifyChainCandidate.t.sol +++ b/test/src/abstract/RainDeployVerifyChainCandidate.t.sol @@ -51,8 +51,9 @@ contract RainDeployVerifyChainCandidateTest is RainDeployVerifyChain { } /// @inheritdoc RainDeploySuitesBase - function candidateSuite() internal pure override returns (DeployCandidate memory) { - return DeployCandidate({ + function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { + candidates = new DeployCandidate[](1); + candidates[0] = DeployCandidate({ snapshot: DeploySuite({ suite: "second-address-candidate", creationCode: type(MockDeployableV2).creationCode, diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index ea528a2..e5394eb 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -28,7 +28,7 @@ import { /// @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 +/// declares two frozen releases and two candidates, and /// `testSnapshotInternallyConsistent` / /// `testSnapshotMatchesSource` run over them here exactly as they /// would in a consumer. @@ -53,6 +53,13 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn checkAnchoredToSource(candidate); } + /// External wrapper for `checkCandidatesAnchoredToSource` so + /// `vm.expectRevert` works at the correct call depth. + /// @param candidates The candidates to check. + function externalCheckCandidatesAnchoredToSource(DeployCandidate[] memory candidates) external pure { + checkCandidatesAnchoredToSource(candidates); + } + /// External wrapper for `checkFrozenSnapshotsReleased` so `vm.expectRevert` /// works at the correct call depth. /// @param paths The frozen record's files. @@ -340,7 +347,35 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn /// the previous test is discriminating rather than a check that always /// fails. function testCandidateAnchoredToSourcePasses() external view { - this.externalCheckAnchoredToSource(candidateSuite()); + this.externalCheckCandidatesAnchoredToSource(checkedCandidateSuites()); + } + + /// The source anchor MUST reach EVERY candidate, not just the first. + /// + /// A loop that stops early is invisible while a repo declares one + /// candidate, and silently stops anchoring the moment it declares two — and + /// a repo with several contracts is precisely where a snapshot generated + /// from the wrong one comes from. So the broken candidate is the LAST one, + /// behind a good one, and the failure has to name it. + function testSourceAnchorReachesEveryCandidate() external { + DeployCandidate[] memory candidates = checkedCandidateSuites(); + assertEq(candidates.length, 2); + + // The first is genuinely fine, so nothing fails before the loop has to + // advance. + this.externalCheckAnchoredToSource(candidates[0]); + + candidates[1] = wrongContractCandidate(); + + vm.expectRevert( + abi.encodeWithSelector( + CandidateSourceMismatch.selector, + "address-registry-candidate", + keccak256(ADDRESS_REGISTRY_CREATION_CODE), + keccak256(type(MockDeployableV2).creationCode) + ) + ); + this.externalCheckCandidatesAnchoredToSource(candidates); } /// Two suites that record the SAME creation code MUST both derive, which @@ -349,7 +384,7 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn /// same address, and the whole set still passes. function testSuitesSharingCreationCodeAllDerive() external { DeploySuite[] memory suites = allSuites(); - assertEq(suites.length, 3); + assertEq(suites.length, 4); assertEq(suites[0].storedDeployedAddress, suites[2].storedDeployedAddress); assertEq(keccak256(suites[0].creationCode), keccak256(suites[2].creationCode)); From ee7fa67227766b6438476f456c393711052f8cb3 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 16:17:12 +0000 Subject: [PATCH 02/11] fix(test): the broadcast fixture reports every key the registry now has `ExampleDeploySuites` declares a second candidate, so the two unknown-suite expectations restate the key list the registry builds from that array. Co-Authored-By: Claude Opus 5 (1M context) --- test/src/abstract/RainDeployBroadcast.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/src/abstract/RainDeployBroadcast.t.sol b/test/src/abstract/RainDeployBroadcast.t.sol index 67656dc..575234e 100644 --- a/test/src/abstract/RainDeployBroadcast.t.sol +++ b/test/src/abstract/RainDeployBroadcast.t.sol @@ -37,7 +37,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "address-registry", - "address-registry-0-0-1, second-address, address-registry-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" ) ); sDeploy.run(); @@ -53,7 +53,7 @@ contract RainDeployBroadcastTest is Test { abi.encodeWithSelector( UnknownDeploymentSuite.selector, "", - "address-registry-0-0-1, second-address, address-registry-candidate" + "address-registry-0-0-1, second-address, address-registry-candidate, second-address-candidate" ) ); sDeploy.run(); From 956bcd2852620ef3dda634d50fe0da8273082164 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 16:23:03 +0000 Subject: [PATCH 03/11] feat(migrations): a registry of which migrations have been applied, per writer Prod-state tests decide what to assert by reading the clock: accept either the pre- or post-migration value until a hardcoded deadline, then only the post value. The window asserts nothing, every migration costs a manual refactor, and the deadline red-lines CI on a date rather than on a fact. The invariant actually wanted is "exactly the value implied by the migrations that have run", so the chain has to hold which ones have. A set of applied migrations rather than a high-water mark. A mark needs a total order that consumers do not have: st0x.deploy already carries two migrations dated 20260722, and one migration split into two scripts because it landed on two networks a week apart. A set represents both exactly, and the order between migrations moves into the assertion, where the semantic dependency actually lives. Namespaced by `msg.sender` rather than gated on a root. The account that applies a migration is a different Safe, deployer or timelock for every consumer and chain, so one root would have to be all of them; and an authority in the creation code would give every consumer a different address, which is the property a deterministic-deploy library exists to keep. A reader that reads the namespace of an authority it already trusts is reading something only that authority could have written. It is an INDEX, not proof. It selects which invariant applies; codehash pins still verify that it holds. Co-Authored-By: Claude Opus 5 (1M context) --- src/concrete/MigrationRegistry.sol | 80 +++++++ src/interface/IMigrationRegistryV1.sol | 141 ++++++++++++ .../concrete/MigrationRegistryApplied.t.sol | 117 ++++++++++ .../concrete/MigrationRegistryRecord.t.sol | 217 ++++++++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 src/concrete/MigrationRegistry.sol create mode 100644 src/interface/IMigrationRegistryV1.sol create mode 100644 test/src/concrete/MigrationRegistryApplied.t.sol create mode 100644 test/src/concrete/MigrationRegistryRecord.t.sol diff --git a/src/concrete/MigrationRegistry.sol b/src/concrete/MigrationRegistry.sol new file mode 100644 index 0000000..ba2ea0c --- /dev/null +++ b/src/concrete/MigrationRegistry.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IMigrationRegistryV1} from "../interface/IMigrationRegistryV1.sol"; + +/// @title MigrationRegistry +/// @notice The whole of `IMigrationRegistryV1`: a writer records one of its own +/// migrations, and anyone reads whether a given writer has recorded a given +/// migration. +/// +/// There is deliberately nothing else. No removal, no upgrade, no pause, and no +/// authority at all — which is the difference from `AddressRegistry`, and the +/// reason this contract has no compile-time constant of any kind. +/// +/// `AddressRegistry` has a root, and a root has to be welded into the creation +/// code so it cannot be rotated, which puts it in the deterministic address. +/// That is workable there because there is one registry of names for the whole +/// organisation. It is not workable here: the account that applies a migration +/// is a different Safe, deployer or timelock for every consumer and every +/// chain, so a root would have to be all of them at once, and baking each +/// consumer's authority into creation code would give each of them a different +/// address for what is meant to be one shared registry. +/// +/// Keying by `msg.sender` removes the authority instead of choosing one. Anyone +/// may write, but only under themselves, so a reader asking about the namespace +/// of an authority it already trusts is reading something only that authority +/// could have written. Every other namespace holds unforgeable claims that no +/// reader asks about. With nothing to configure there is also no rollout state +/// in which this contract is inert: it does its whole job the moment it exists +/// on a chain. +/// +/// A record is append-only per writer. `record` refuses a migration the caller +/// has already recorded, which is what makes re-running a migration fail rather +/// than repeat, and there is no way to unrecord one — a record describes +/// something that happened, and nothing that happened stops having happened. +/// +/// The storage mapping is `internal` rather than `public`: `applied` refuses +/// the zero writer and the zero migration, and a public mapping's generated +/// getter would answer both with `false`, which is exactly the silent +/// wrong-branch this contract reverts to prevent. +contract MigrationRegistry is IMigrationRegistryV1 { + /// The records, namespaced by writer. Not `public`: the only reader is + /// `applied`, which refuses the two inputs that can only be mistakes. + mapping(address writer => mapping(bytes32 migration => bool recorded)) internal sApplied; + + /// @inheritdoc IMigrationRegistryV1 + function record(bytes32 migration) external { + // Checked before the already-recorded read, so an uninitialised id is + // reported as the mistake it is rather than as a first record of zero. + if (migration == bytes32(0)) { + revert ZeroMigration(); + } + // There is deliberately no zero-writer case here. `msg.sender` cannot + // be the zero address, so the zero namespace is unreachable for writes + // and a guard on it would be unreachable code pretending to be a check. + if (sApplied[msg.sender][migration]) { + revert MigrationAlreadyRecorded(msg.sender, migration); + } + sApplied[msg.sender][migration] = true; + emit Migrated(msg.sender, migration); + } + + /// @inheritdoc IMigrationRegistryV1 + /// @dev Both refusals are about a caller that has not supplied what it + /// thinks it has. Neither can ever be a real record: nothing originates + /// from the zero address, and `record` will not write the zero id — so + /// answering `false` for either would be answering a question the caller + /// did not mean to ask, and answering it with the value that sends it down + /// its pre-migration branch. + function applied(address writer, bytes32 migration) external view returns (bool) { + if (writer == address(0)) { + revert ZeroWriter(); + } + if (migration == bytes32(0)) { + revert ZeroMigration(); + } + return sApplied[writer][migration]; + } +} diff --git a/src/interface/IMigrationRegistryV1.sol b/src/interface/IMigrationRegistryV1.sol new file mode 100644 index 0000000..b47fa76 --- /dev/null +++ b/src/interface/IMigrationRegistryV1.sol @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +/// @title IMigrationRegistryV1 +/// @notice A per-writer record of which migrations have been applied, with +/// exactly two operations: a writer records one of its own migrations +/// (`record`), and anyone reads whether a given writer has recorded a given +/// migration (`applied`). There is no removal, no upgrade and no authority +/// beyond the writer over its own namespace, and an implementation MUST NOT add +/// any. +/// +/// It exists so that a test can decide what to assert by reading what happened +/// on chain rather than by reading the clock. Without it, a test that spans a +/// migration accepts EITHER the pre-migration or the post-migration value until +/// a hardcoded deadline, which asserts nothing at all during the one window +/// where it matters most, and red-lines on a date rather than on a fact once +/// the deadline passes. With it, a test asserts EXACTLY the value implied by +/// the migrations that have run, in both branches. +/// +/// ## An index, not proof +/// +/// This registry says which invariant applies. It does NOT say that the +/// invariant holds. A multisig can act out of band — a beacon is upgraded by +/// hand and nothing here moves — and then a reader would confidently assert the +/// wrong state. +/// +/// So a consumer keeps both layers, with distinct jobs: this registry SELECTS +/// which invariant applies, and codehash or bytecode pins VERIFY that it +/// actually holds. Replacing the pins with this registry trades a clock-guess +/// for a bookkeeping-guess, which is not an improvement. An implementation MUST +/// NOT offer anything that invites it, and in particular MUST NOT record +/// anything about the state a migration produced — only that it was recorded. +/// +/// ## The namespace is the writer, and that is the whole access control +/// +/// A record is keyed by the account that wrote it. Anyone may write, but only +/// to their own namespace, so a reader that reads the namespace of an authority +/// it already trusts is reading something only that authority could have +/// written. Records under any other namespace are unforgeable garbage that no +/// reader asks for. +/// +/// This is deliberately not a root authority. The account that applies a +/// migration differs per consumer, per chain and per migration — a Safe +/// executing a bundle, a deployer EOA broadcasting a script, a timelock — so a +/// single root would have to be all of them at once. It is also what lets the +/// implementation be identical for every consumer, and therefore live at one +/// deterministic address on every chain: an authority baked into creation code +/// would give every consumer a different address, which is the property this +/// registry exists inside a deterministic-deploy library to keep. +/// +/// A compromised writer can therefore only lie about its own migrations, to +/// readers that have chosen to trust it. It cannot touch anybody else's record, +/// and it cannot unrecord its own. +/// +/// ## Identity is opaque +/// +/// A migration is an opaque 32-byte value. This interface says nothing about +/// how one is derived — hashed from a script path, a name, a counter — and an +/// implementation MUST NOT constrain it. Two callers agreeing on an id is +/// entirely their business. +/// +/// The convention that suits scripts-as-migrations is the hash of the script's +/// identity, e.g. `keccak256("script/20260623-upgrade-receipt-vaults.s.sol")`. +/// A date alone is not enough: two migrations authored on one day collide, and +/// consumers do author two on one day. An id is fixed at the moment it is first +/// recorded, so a script renamed afterwards keeps the id it was recorded under +/// rather than acquiring a new one — which is why the id belongs in a named +/// constant beside the script, not derived from a path at the call site. +interface IMigrationRegistryV1 { + /// Thrown when `record` is called with the zero migration id, and by + /// `applied` when it is asked about one. The zero id is what an + /// uninitialised `bytes32` constant reads as, and an uninitialised id is + /// never a migration anybody meant to name. Rejected in both directions + /// because the read is the dangerous one: answering `false` would silently + /// send a caller down its pre-migration branch. + error ZeroMigration(); + + /// Thrown by `applied` when asked about the zero writer. No transaction can + /// originate from the zero address, so the zero namespace is provably empty + /// and the answer would always be `false` — an unresolved or unset writer + /// constant would therefore read as "nothing has been applied" rather than + /// as the mistake it is. + /// + /// There is no matching case on `record`: `msg.sender` is never zero, so + /// the zero namespace cannot be written to in the first place. + error ZeroWriter(); + + /// Thrown when a writer records a migration it has already recorded. This + /// is what makes running a migration twice structurally impossible rather + /// than a warning in a workflow dropdown asking a human not to re-dispatch + /// it: a script consults `applied` before it acts, and this is the backstop + /// under that consultation. + /// @param writer The namespace, which is the caller. + /// @param migration The migration already recorded under it. + error MigrationAlreadyRecorded(address writer, bytes32 migration); + + /// Emitted every time a migration is recorded. A migration is recorded at + /// most once per writer, so the log is the complete history of the registry + /// and the only way to discover a record without already knowing the id. + /// @param writer The namespace, which is the caller. + /// @param migration The migration recorded. + event Migrated(address indexed writer, bytes32 indexed migration); + + /// Records `migration` as applied under the caller's namespace. + /// + /// The implementation MUST revert `ZeroMigration` if `migration` is zero, + /// MUST revert `MigrationAlreadyRecorded` if the caller has already + /// recorded it, and MUST NOT provide any way to unrecord one. On success it + /// MUST emit `Migrated`. + /// + /// A caller SHOULD record the migration in the same atomic unit as the + /// migration itself where it can — a Safe appends this call to the bundle + /// it is already executing — so that the record and the change it describes + /// cannot land apart. Where they cannot be atomic, record LAST: a record + /// that never landed leaves a reader asserting the pre-migration state, + /// which the verification layer then catches loudly, and leaves a re-run + /// possible. A record that landed for a migration that did not is the + /// harder state to get out of. + /// @param migration The migration to record. + function record(bytes32 migration) external; + + /// Whether `writer` has recorded `migration`. + /// + /// The implementation MUST revert `ZeroWriter` or `ZeroMigration` rather + /// than answering about either, and MUST answer `false` — not revert — for + /// a nonzero writer that has simply not recorded a nonzero migration. + /// + /// That `false` is the deliberate difference from a registry whose reads + /// revert on an unknown key. "This migration has not been applied here" is + /// a legitimate, expected answer that a caller branches on and asserts the + /// pre-migration state for; it is the ordinary state of every migration + /// before it runs, and of every migration on a chain that never got it. A + /// revert there would leave a caller with nothing to say about the state it + /// is actually looking at, which is the whole failure this registry + /// removes. + /// @param writer The namespace to read. Never the zero address. + /// @param migration The migration to ask about. Never zero. + /// @return Whether `writer` has recorded `migration`. + function applied(address writer, bytes32 migration) external view returns (bool); +} diff --git a/test/src/concrete/MigrationRegistryApplied.t.sol b/test/src/concrete/MigrationRegistryApplied.t.sol new file mode 100644 index 0000000..a23837e --- /dev/null +++ b/test/src/concrete/MigrationRegistryApplied.t.sol @@ -0,0 +1,117 @@ +// 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 {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; + +/// @title MigrationRegistryAppliedTest +/// @notice A test suite for `MigrationRegistry.applied`: it answers a recorded +/// migration `true`, an unrecorded one `false`, refuses the two inputs that can +/// only be mistakes, and is the only reader. +contract MigrationRegistryAppliedTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + MigrationRegistry internal sRegistry; + + function setUp() external { + sRegistry = new MigrationRegistry(); + } + + /// An unrecorded migration answers `false` rather than reverting. This is + /// the deliberate difference from a registry whose reads revert on an + /// unknown key: "not applied here" is the ordinary state of every migration + /// before it runs and of every migration on a chain that never got it, and + /// it is the answer a caller branches on to assert the pre-migration state + /// exactly. A revert would leave the caller with nothing to say about the + /// state it is actually looking at. + function testAppliedUnrecordedIsFalse(address writer, bytes32 migration) external view { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + assertFalse(sRegistry.applied(writer, migration)); + } + + /// Reading does not consume or alter a record, so the same question asked + /// twice answers the same way. + function testAppliedIsIdempotent(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(writer, migration)); + assertTrue(sRegistry.applied(writer, migration)); + } + + /// The zero writer is refused rather than answered. No transaction + /// originates from the zero address, so that namespace is provably empty + /// and `false` would be the answer forever — an unresolved writer constant + /// would read as "nothing has been applied" instead of as the mistake it + /// is, and send its caller down the pre-migration branch on every chain. + function testAppliedZeroWriterReverts(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.applied(address(0), migration); + } + + /// The zero migration id is refused for the same reason in the other + /// direction: `record` will not write it, so it can never be a real record. + function testAppliedZeroMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + sRegistry.applied(writer, bytes32(0)); + } + + /// The writer is checked before the migration, so a caller that has zeroed + /// both is told about the namespace first and gets one stable answer rather + /// than one that depends on which check happens to run. + function testAppliedZeroWriterCheckedFirst() external { + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.applied(address(0), bytes32(0)); + } + + /// A refusal is not a state change: the zero cases revert on a registry + /// that holds records exactly as they do on an empty one, and leave those + /// records intact. + function testAppliedZeroRefusalLeavesRecordsIntact(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.applied(address(0), migration); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + sRegistry.applied(writer, bytes32(0)); + + assertTrue(sRegistry.applied(writer, migration)); + } + + /// `applied` is the only reader. The records mapping is not `public`, so + /// the getter a `public` mapping would generate — which answers the zero + /// writer and the zero migration with `false`, the exact silent + /// wrong-branch these refusals exist to prevent — does not exist. + function testAppliedNoGeneratedMappingGetter(address writer, bytes32 migration) external { + (bool success,) = + address(sRegistry).call(abi.encodeWithSignature("sApplied(address,bytes32)", writer, migration)); + assertFalse(success); + } + + /// There is no other entry point at all: no fallback, no receive, and + /// nothing beyond the two `IMigrationRegistryV1` functions, so an unknown + /// selector reverts instead of being silently absorbed. + function testAppliedNoOtherEntryPoint(bytes4 selector, bytes32 migration) external { + vm.assume(selector != IMigrationRegistryV1.applied.selector); + vm.assume(selector != IMigrationRegistryV1.record.selector); + + (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, address(this), migration)); + assertFalse(success); + } +} diff --git a/test/src/concrete/MigrationRegistryRecord.t.sol b/test/src/concrete/MigrationRegistryRecord.t.sol new file mode 100644 index 0000000..6cbbbca --- /dev/null +++ b/test/src/concrete/MigrationRegistryRecord.t.sol @@ -0,0 +1,217 @@ +// 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 {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; + +/// @title MigrationRegistryRecordTest +/// @notice A test suite for `MigrationRegistry.record`: who a record belongs +/// to, that a migration is recorded at most once, and what a record may never +/// become. +contract MigrationRegistryRecordTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + MigrationRegistry internal sRegistry; + + function setUp() external { + sRegistry = new MigrationRegistry(); + } + + /// Anyone may record, and the record lands under the caller. There is no + /// authority to be refused by, which is the whole access-control design: + /// the namespace IS the caller. + function testRecordAnyCallerRecordsUnderItself(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(writer, migration)); + } + + /// A record is confined to the caller's namespace. Recording under one + /// writer says nothing about any other, which is what makes a reader's + /// choice of namespace the whole of who it trusts — a hostile caller can + /// record whatever it likes and reach nobody. + function testRecordDoesNotReachAnotherNamespace(address writer, address other, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(other != address(0)); + vm.assume(writer != other); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(writer, migration)); + assertFalse(sRegistry.applied(other, migration)); + } + + /// Two writers may record the same migration id independently, and each + /// answers only for itself. Ids are opaque and namespaces are unrelated, so + /// a shared id is not a collision. + function testRecordSameMigrationUnderTwoWriters(address writer, address other, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(other != address(0)); + vm.assume(writer != other); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + vm.prank(other); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(writer, migration)); + assertTrue(sRegistry.applied(other, migration)); + } + + /// Migrations are independent within one namespace: recording one says + /// nothing about any other. This is what a set buys over a high-water mark + /// — migrations applied out of order, or one applied and its predecessor + /// not, are representable exactly rather than papered over by a single + /// comparable value. + function testRecordDistinctMigrations(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + vm.assume(migrationA != bytes32(0)); + vm.assume(migrationB != bytes32(0)); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(migrationA); + + assertTrue(sRegistry.applied(writer, migrationA)); + assertFalse(sRegistry.applied(writer, migrationB)); + + vm.prank(writer); + sRegistry.record(migrationB); + + assertTrue(sRegistry.applied(writer, migrationA)); + assertTrue(sRegistry.applied(writer, migrationB)); + } + + /// Recording twice is refused. This is what makes running a migration twice + /// fail rather than repeat: a re-dispatched script cannot quietly record + /// its way to looking like a first run. + function testRecordTwiceReverts(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) + ); + vm.prank(writer); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(writer, migration)); + } + + /// A migration another writer has already recorded is still a FIRST record + /// for this one. The refusal is per namespace, not global, or one consumer + /// choosing a common id would lock every other consumer out of it. + function testRecordTwiceIsPerWriter(address writer, address other, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(other != address(0)); + vm.assume(writer != other); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + vm.prank(other); + sRegistry.record(migration); + + assertTrue(sRegistry.applied(other, migration)); + } + + /// The zero migration id is refused. It is what an uninitialised `bytes32` + /// constant reads as, and there is deliberately no way to record one, which + /// is what lets `applied` refuse it as a mistake rather than have to answer + /// about it. + function testRecordZeroMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + vm.prank(writer); + sRegistry.record(bytes32(0)); + } + + /// The zero id is refused BEFORE the already-recorded read, so it is always + /// reported as `ZeroMigration` and never as a first record that later + /// collides. + function testRecordZeroMigrationCheckedFirst(address writer) external { + vm.assume(writer != address(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + vm.prank(writer); + sRegistry.record(bytes32(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + vm.prank(writer); + sRegistry.record(bytes32(0)); + } + + /// Ids are opaque: nothing about a migration's bytes changes how it is + /// stored or read, including ids no hashing convention would produce. + function testRecordOpaqueMigrationIds(address writer) external { + vm.assume(writer != address(0)); + + bytes32[2] memory migrations = [bytes32(uint256(1)), bytes32(type(uint256).max)]; + for (uint256 i = 0; i < migrations.length; i++) { + MigrationRegistry registry = new MigrationRegistry(); + vm.prank(writer); + registry.record(migrations[i]); + assertTrue(registry.applied(writer, migrations[i])); + } + } + + /// `Migrated` is emitted with the writer and migration both indexed, so the + /// log can be filtered by either. The log is the only enumeration of the + /// registry, so a record that does not emit is a record nobody can find. + function testRecordEvent(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.recordLogs(); + vm.prank(writer); + sRegistry.record(migration); + 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("Migrated(address,bytes32)")); + assertEq(entries[0].topics[1], bytes32(uint256(uint160(writer)))); + assertEq(entries[0].topics[2], migration); + assertEq(entries[0].data.length, 0); + } + + /// A refused `record` emits nothing, so a failed record can never be + /// mistaken for a record by anything reading the logs — which for a + /// re-dispatched migration is exactly the mistake that matters. + function testRecordNoEventOnRevert(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + + vm.prank(writer); + sRegistry.record(migration); + + vm.recordLogs(); + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) + ); + vm.prank(writer); + sRegistry.record(migration); + assertEq(vm.getRecordedLogs().length, 0); + + vm.recordLogs(); + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + vm.prank(writer); + sRegistry.record(bytes32(0)); + assertEq(vm.getRecordedLogs().length, 0); + } +} From 5cf02e459ddee6dde9eeef4837e689037370eaf6 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 16:33:54 +0000 Subject: [PATCH 04/11] wip(registry): rename the address-registry suites to the shared registry shape Incomplete handoff commit from an agent stopped at the context limit mid-mutation-run. Not verified. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 22 ++++++++++--------- README.md | 14 ++++++++---- script/Build.sol | 6 ++--- script/Deploy.sol | 6 ++--- slither.config.json | 2 +- ...loySuites.sol => RegistryDeploySuites.sol} | 4 ++-- test/src/abstract/RainDeployVerifyChain.t.sol | 2 +- .../abstract/RainDeployVerifySnapshot.t.sol | 11 ++++++++-- .../RegistryDeployChain.t.sol} | 4 ++-- .../RegistryDeploySnapshot.t.sol} | 6 ++--- 10 files changed, 46 insertions(+), 31 deletions(-) rename src/abstract/{AddressRegistryDeploySuites.sol => RegistryDeploySuites.sol} (97%) rename test/src/{concrete/AddressRegistryDeployChain.t.sol => abstract/RegistryDeployChain.t.sol} (87%) rename test/src/{concrete/AddressRegistryDeploySnapshot.t.sol => abstract/RegistryDeploySnapshot.t.sol} (80%) diff --git a/CLAUDE.md b/CLAUDE.md index b67a857..e63eff1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,12 +208,12 @@ suite fails naming the valid ones rather than on a missing key. repos that bootstrap one chain per dispatch. **`script/Deploy.sol`** — -`contract Deploy is AddressRegistryDeploySuites, +`contract Deploy is RegistryDeploySuites, 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 +**`src/abstract/RegistryDeploySuites.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 @@ -233,11 +233,13 @@ Four groups, sorted by what they are anchored to: 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** (`RainDeployVerifySnapshot`) — the candidate's +2. **Anchored to source** (`RainDeployVerifySnapshot`) — EVERY 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. + a wrong-contract snapshot. Candidates 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. Every one, and refusing an + empty list, because a candidate the loop never reaches is a contract whose + snapshot nothing anywhere anchors — see `NoDeployCandidates`. 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()` @@ -246,9 +248,9 @@ Four groups, sorted by what they are anchored to: 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. + release and the candidate it was cut from are byte-identical from the moment + the release is cut, so matching the whole declaration would let a 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 @@ -256,7 +258,7 @@ Four groups, sorted by what they are anchored to: 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 +a deployment that happened; a 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 diff --git a/README.md b/README.md index 7f03e52..11f0d96 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ and the addresses that must already be on chain before it can be deployed. // 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); + function candidateSuites() internal pure override returns (DeployCandidate[] memory); } // script/Deploy.sol @@ -98,19 +98,25 @@ 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 | | 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 | +| Chain | the networks | never deployed, or not there any more | anything about a 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 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 +catches it, and it applies to the **candidates 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. +It runs over EVERY candidate, and a declaration that names none at all is +refused with `NoDeployCandidates` rather than passed as a loop with nothing in +it. A candidate the source anchor never reaches is a contract whose snapshot +nothing anywhere anchors, and a repo with several contracts is exactly where a +snapshot generated from the wrong one comes from. + 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 +live on every supported network" is either true of it or a defect. A 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. diff --git a/script/Build.sol b/script/Build.sol index 74e3951..cdadbc2 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,7 +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 {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; @@ -24,7 +24,7 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// 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. +/// `RegistryDeploySuites.releasedSuites()` enumerates. /// /// 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 @@ -46,7 +46,7 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// 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 { +contract Build is Script, RegistryDeploySuites { /// @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"; diff --git a/script/Deploy.sol b/script/Deploy.sol index dfceb7f..ed1f371 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -3,13 +3,13 @@ pragma solidity =0.8.25; import {RainDeployBroadcast} from "../src/abstract/RainDeployBroadcast.sol"; -import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploySuites.sol"; +import {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; /// @title Deploy /// @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 +/// Empty on purpose. The suites come from `RegistryDeploySuites`, 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 @@ -30,4 +30,4 @@ import {AddressRegistryDeploySuites} from "../src/abstract/AddressRegistryDeploy /// `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 {} +contract Deploy is RegistryDeploySuites, RainDeployBroadcast {} diff --git a/slither.config.json b/slither.config.json index acb8796..747d60d 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", + "filter_paths": "dependencies/forge-std-|src/abstract/(RainDeploy(SuitesBase|Broadcast|VerifyBase|VerifyChain|VerifySnapshot)|RegistryDeploySuites)\\.sol", "detectors_to_exclude": "assembly" } diff --git a/src/abstract/AddressRegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol similarity index 97% rename from src/abstract/AddressRegistryDeploySuites.sol rename to src/abstract/RegistryDeploySuites.sol index 2737433..5453883 100644 --- a/src/abstract/AddressRegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -11,7 +11,7 @@ import { import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol"; -/// @title AddressRegistryDeploySuites +/// @title RegistryDeploySuites /// @notice Everything this repo deploys, declared ONCE. /// /// Three contracts inherit this and nothing else declares a suite: @@ -38,7 +38,7 @@ import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol" /// 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 { +abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase /// @dev Generated by `script/Build.sol` from the frozen /// `src/generated//` record, in the same call that writes it. The diff --git a/test/src/abstract/RainDeployVerifyChain.t.sol b/test/src/abstract/RainDeployVerifyChain.t.sol index 7ce3ffc..cd805e3 100644 --- a/test/src/abstract/RainDeployVerifyChain.t.sol +++ b/test/src/abstract/RainDeployVerifyChain.t.sol @@ -196,7 +196,7 @@ contract RainDeployVerifyChainTest is ExampleDeploySuites, RainDeployVerifyChain assertEq(vm.getNonce(secondDeployedAddress()), 0); DerivedDeploy[] memory derived = deriveDeployments(allSuites()); - assertEq(derived.length, 3); + assertEq(derived.length, 4); assertEq(ADDRESS_REGISTRY_DEPLOYED_ADDRESS.code, ADDRESS_REGISTRY_RUNTIME_CODE); assertEq(secondDeployedAddress().code, secondRuntimeCode()); diff --git a/test/src/abstract/RainDeployVerifySnapshot.t.sol b/test/src/abstract/RainDeployVerifySnapshot.t.sol index e5394eb..0da9f97 100644 --- a/test/src/abstract/RainDeployVerifySnapshot.t.sol +++ b/test/src/abstract/RainDeployVerifySnapshot.t.sol @@ -365,12 +365,19 @@ contract RainDeployVerifySnapshotTest is ExampleDeploySuites, RainDeployVerifySn // advance. this.externalCheckAnchoredToSource(candidates[0]); - candidates[1] = wrongContractCandidate(); + // The break keeps the SECOND candidate's own key, so the failure names + // the entry that actually failed rather than the good one sitting in + // front of it — a loop that reported a fixed entry, or the first, would + // otherwise be indistinguishable from one that reported the right one. + DeployCandidate memory broken = wrongContractCandidate(); + broken.snapshot.suite = candidates[1].snapshot.suite; + assertEq(broken.snapshot.suite, "second-address-candidate"); + candidates[1] = broken; vm.expectRevert( abi.encodeWithSelector( CandidateSourceMismatch.selector, - "address-registry-candidate", + "second-address-candidate", keccak256(ADDRESS_REGISTRY_CREATION_CODE), keccak256(type(MockDeployableV2).creationCode) ) diff --git a/test/src/concrete/AddressRegistryDeployChain.t.sol b/test/src/abstract/RegistryDeployChain.t.sol similarity index 87% rename from test/src/concrete/AddressRegistryDeployChain.t.sol rename to test/src/abstract/RegistryDeployChain.t.sol index ea62f08..638b4d8 100644 --- a/test/src/concrete/AddressRegistryDeployChain.t.sol +++ b/test/src/abstract/RegistryDeployChain.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; -import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; +import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; /// @title AddressRegistryDeployChainTest /// @notice Whether `AddressRegistry` is actually live, with the code this repo @@ -25,4 +25,4 @@ import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistry /// --no-match-contract Chain` still runs every snapshot assertion, /// whether the deployment is missing or the RPC endpoints are merely /// unreachable. -contract AddressRegistryDeployChainTest is AddressRegistryDeploySuites, RainDeployVerifyChain {} +contract AddressRegistryDeployChainTest is RegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/concrete/AddressRegistryDeploySnapshot.t.sol b/test/src/abstract/RegistryDeploySnapshot.t.sol similarity index 80% rename from test/src/concrete/AddressRegistryDeploySnapshot.t.sol rename to test/src/abstract/RegistryDeploySnapshot.t.sol index 6c97997..b1be36b 100644 --- a/test/src/concrete/AddressRegistryDeploySnapshot.t.sol +++ b/test/src/abstract/RegistryDeploySnapshot.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {RainDeployVerifySnapshot} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; -import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistryDeploySuites.sol"; +import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; /// @title AddressRegistryDeploySnapshotTest /// @notice The deploy-pin assertions for `AddressRegistry` that need no @@ -18,6 +18,6 @@ import {AddressRegistryDeploySuites} from "../../../src/abstract/AddressRegistry /// 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: `AddressRegistryDeploySuites` says which versions exist and +/// point: `RegistryDeploySuites` says which versions exist and /// `RainDeployVerifySnapshot` says what is true of them. -contract AddressRegistryDeploySnapshotTest is AddressRegistryDeploySuites, RainDeployVerifySnapshot {} +contract AddressRegistryDeploySnapshotTest is RegistryDeploySuites, RainDeployVerifySnapshot {} From f9a8c882651a7a386e549cbe41c8d9fb8774d7c0 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 17:54:43 +0000 Subject: [PATCH 05/11] feat(migrations): a second deploy candidate, and a registry of what was applied `MigrationRegistry` is declared as a second candidate on `RegistryDeploySuites`; `LibMigrationRegistry.applied`/`.record` are codehash-verified as `LibAddressRegistry.resolve` is, namespaced by `msg.sender` so no root key exists. `Build.sol` drives regeneration, both lib writers and `cutRelease`'s freeze names from one `generatedContracts()` list, so a third contract cannot reach one and not another. `GeneratedSnapshotShapeTest` walks the candidate directory and asserts the walk covers every declared candidate. `forge test` 179 passed / 0 failed with forks. `slither .` not yet re-run. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/manual-sol-artifacts.yaml | 32 ++- CLAUDE.md | 102 ++++++-- README.md | 86 ++++++- script/Build.sol | 156 +++++++----- script/Deploy.sol | 11 +- src/abstract/RegistryDeploySuites.sol | 84 +++++-- src/generated/candidate/MigrationRegistry.sol | 20 ++ src/lib/LibMigrationRegistry.sol | 127 ++++++++++ src/lib/LibMigrationRegistryDeploy.sol | 20 ++ src/lib/LibMigrationRegistryReleased.sol | 28 +++ test/concrete/MockMigrationRecorder.sol | 33 +++ test/src/abstract/RegistryDeployChain.t.sol | 36 +-- .../src/abstract/RegistryDeploySnapshot.t.sol | 17 +- test/src/lib/GeneratedSnapshotShape.t.sol | 134 ++++++++--- test/src/lib/LibMigrationRegistry.t.sol | 223 ++++++++++++++++++ 15 files changed, 931 insertions(+), 178 deletions(-) create mode 100644 src/generated/candidate/MigrationRegistry.sol create mode 100644 src/lib/LibMigrationRegistry.sol create mode 100644 src/lib/LibMigrationRegistryDeploy.sol create mode 100644 src/lib/LibMigrationRegistryReleased.sol create mode 100644 test/concrete/MockMigrationRecorder.sol create mode 100644 test/src/lib/LibMigrationRegistry.t.sol diff --git a/.github/workflows/manual-sol-artifacts.yaml b/.github/workflows/manual-sol-artifacts.yaml index 5e05316..a6f6248 100644 --- a/.github/workflows/manual-sol-artifacts.yaml +++ b/.github/workflows/manual-sol-artifacts.yaml @@ -1,23 +1,37 @@ 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. +# The on-chain deploy, run by hand. This repo carries deployed concretes +# (`AddressRegistry`, `MigrationRegistry`) 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 `AddressRegistryDeployChainTest` passes -# on every supported network, then push the `sol-v*` tag. +# Order is: dispatch this once per suite, confirm `RegistryDeployChainTest` +# 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: + inputs: + suite: + type: choice + required: true + description: | + Which declared suite to broadcast. One dispatch deploys one suite, + because `DEPLOYMENT_SUITE` selects one — a repo with two deployed + contracts is two dispatches. Offered as a choice rather than typed, + so a key that no suite declares cannot be dispatched at all; the + script still refuses one, naming the valid keys, if this list falls + behind the declaration. + options: + - address-registry + - migration-registry 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 + suite: ${{ inputs.suite }} secrets: inherit diff --git a/CLAUDE.md b/CLAUDE.md index f492480..8b16ac8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,14 +124,65 @@ 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. +against it by `RegistryDeploySnapshotTest`. GENERATED by `script/Build.sol`, +aliasing the rolling `src/generated/candidate/` snapshot. + +**`src/interface/IMigrationRegistryV1.sol`** — the migration registry interface: +a writer records one of its own migrations (`record`), and anyone reads whether +a given writer has recorded a given migration (`applied`). There is no removal, +no upgrade and no authority beyond the writer over its own namespace. + +It exists so a prod-state test decides what to assert by reading what happened +on chain rather than by reading the clock. Without it, a test that spans a +migration accepts either the pre- or the post-migration value until a hardcoded +deadline — which asserts nothing during the one window where it matters, and +red-lines CI on a date rather than on a fact once the deadline passes. + +It is an INDEX, not proof. It says which invariant applies; codehash and +bytecode pins are what say the invariant holds. A multisig can act out of band, +so replacing the pins with this would trade a clock-guess for a bookkeeping +guess. + +**`src/concrete/MigrationRegistry.sol`** — the implementation. Two functions and +nothing else, and — unlike `AddressRegistry` — no compile-time constant of any +kind. + +The namespace is `msg.sender`, which is the whole access control. A root would +have to be welded into the creation code, as `ADDRESS_REGISTRY_ROOT` is, and the +account that applies a migration is a different Safe, deployer or timelock for +every consumer and every chain — so one root would have to be all of them, and +baking each consumer's authority in would give each of them a different address +for what is meant to be one shared registry. Anyone may write, but only under +themselves, so a reader asking about an authority it already trusts is reading +something only that authority could have written. + +With nothing to configure there is no rollout state in which it is inert: it +does its whole job the moment it exists on a chain, which is the opposite of +`AddressRegistry` under a zero root. + +**`src/lib/LibMigrationRegistry.sol`** — the consumer surface: `applied` and +`record`, both verifying the registry's code hash first, exactly as +`LibAddressRegistry.resolve` does. There is deliberately no broadcast runner: +the dominant real migration shape is a Safe executing a bundle that never +broadcasts, and such a script appends `record` to the bundle it is already +emitting, which is what makes the record atomic with the migration. + +**`src/lib/LibMigrationRegistryDeploy.sol`** — its pins, generated exactly as +`LibAddressRegistryDeploy` is. ### 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` is -the deploy record, from `forge script script/Build.sol`. +hand-maintained hex anywhere: `src/generated/candidate/` holds one deploy record +per deployed contract — `AddressRegistry.sol` and `MigrationRegistry.sol` — from +`forge script script/Build.sol`. + +`script/Build.sol` declares those contracts ONCE, in `generatedContracts()`, and +the regeneration, both lib writers and the freeze all read that list. A contract +added to it is generated, aliased, released and frozen together. That matters +most for the freeze: a contract regenerated but absent from the names `freeze` +is given is a contract silently missing from the release, and a tag that never +held it has nothing missing from it for anything downstream to notice. A compiler or optimiser change is therefore "run the script, commit". Never hand-edit a generated file. @@ -188,8 +239,9 @@ 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. +declaration. `src/concrete/AddressRegistry.sol` and +`src/concrete/MigrationRegistry.sol` are ordinary deployed contracts, 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 @@ -219,8 +271,9 @@ RainDeployBroadcast {}`. Empty on purpose: the suites and the broadcast are both inherited. Run only via the `Manual sol artifacts` workflow. -**`src/abstract/RegistryDeploySuites.sol`** — this repo's own declaration, -inherited by `script/Deploy.sol` and by both pins test contracts. +**`src/abstract/RegistryDeploySuites.sol`** — this repo's own declaration, one +named candidate per deployed registry, inherited by `script/Deploy.sol`, +`script/Build.sol` and both pins test contracts. **`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every deploy repo inherits instead of hand-writing. @@ -314,24 +367,25 @@ expected addresses, expected code hashes, and dependency lists. 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 + (`rainix-tag-release`), because this repo carries deployed concretes whose pins consumers rely on. `[package].version` is the LAST released version and - moves only in lockstep with its snapshot. + moves only in lockstep with its snapshots. - **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 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. + things. `script/Deploy.sol` broadcasts the suite `DEPLOYMENT_SUITE` names to + every network in `supportedNetworks()`, dispatched by hand through + `.github/workflows/manual-sol-artifacts.yaml`, whose `suite` input is a choice + over the declared keys. One suite per dispatch, so this repo's two registries + are two dispatches. 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. + + `RegistryDeployChainTest` 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 diff --git a/README.md b/README.md index 11f0d96..43a59b5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ It answers: deployment? - Is every version I have ever released still live, with the code I compiled, on every network I support? +- Which operational migrations have actually been applied on this chain, so a + test can assert the state they imply instead of guessing from a date? Approach: @@ -33,6 +35,8 @@ 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. +- A migration registry, so operational scripts record what they applied and + tests assert the state that implies rather than branching on a deadline. - One inherited deploy-pin verification, parameterized over versions, rather than assertions hand-enumerated per version and per chain in every deploy repo. @@ -174,30 +178,90 @@ 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. +## Migration registry + +`MigrationRegistry` records that a migration has been applied: a writer records +one of its own (`record`), and anyone reads whether a given writer has recorded +a given one (`applied`). There is no removal and no upgrade. + +It exists because prod-state tests otherwise decide what to assert by reading +the **clock**. The pattern that emerges without it is a dual-state invariant — +accept either the pre- or the post-migration value until a hardcoded deadline, +and only the post value after it. That window asserts nothing during the one +period you most want to know about, every migration costs a manual refactor to +add and another to delete, and once the deadline passes CI red-lines on a date +rather than on a fact. What you actually want is "**exactly** the value implied +by the migrations that have run", which needs the chain to hold which ones have: + +```solidity +if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2)) { + assertEq(vault.owner(), NEW_OWNER); +} else { + assertEq(vault.owner(), OLD_OWNER); +} +``` + +Both branches assert exactly. Neither skips, and `applied` answering `false` is +an ordinary expected answer rather than a revert — it is the state of every +migration before it runs, and of every migration on a chain that never got it. + +**A set of applied migrations, not a high-water mark.** A mark needs a total +order consumers do not have: two migrations authored on one day collide, and one +migration split across two scripts because it landed on two networks a week +apart cannot be one comparable value at all. A set represents both exactly, and +the ordering between migrations moves into the assertion — +`applied(V5) ? … : applied(V4) ? … : …` — which is where the semantic dependency +actually lives. + +**The namespace is `msg.sender`, and that is the whole access control.** Anyone +may write, but only under themselves, so a reader asking about the namespace of +an authority it already trusts is reading something only that authority could +have written; every other namespace holds unforgeable claims nobody asks about. +A root would have to be welded into the creation code, the way +`ADDRESS_REGISTRY_ROOT` is — and the account that applies a migration is a +different Safe, deployer or timelock for every consumer and every chain, so one +root would have to be all of them, and baking each consumer's authority in would +give each a different address for what is meant to be one shared registry. With +nothing to configure there is also no rollout state in which it is inert. + +**An index, not proof.** The registry says which invariant applies. It does not +say the invariant holds — a multisig can act out of band and nothing here moves. +Keep both layers: this selects, codehash and bytecode pins verify. Replacing the +pins with it trades a clock-guess for a bookkeeping-guess. + +`LibMigrationRegistry` is the surface — `applied` and `record`, both verifying +the registry's code hash first. There is deliberately **no broadcast runner**: +the dominant real shape is a Safe executing a bundle that never broadcasts, and +such a script appends `record` to the bundle it is already emitting, which makes +the record atomic with the migration it describes. + ## 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.** `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. + workflow, choosing a `suite`. It runs `script/Deploy.sol` and broadcasts that + suite to every network in `supportedNetworks()`. One suite per dispatch, so + this repo's two registries are two dispatches. `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.** `RegistryDeployChainTest` passes only once every **released** + suite is live on every supported network, with the code that release froze. + This repo has released none, so today it has nothing to check and passes; it + gets a subject the moment step 3 freezes one, and is red from then until step + 1 has been run everywhere. That is the order these steps are in. 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. +This is a deploy repo: it carries deployed concretes whose addresses and +codehashes 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 +`src/generated//` snapshots, and only a release moves it. Every version published under the previous merge-driven lifecycle stays published; consumers pin exact versions and are unaffected. diff --git a/script/Build.sol b/script/Build.sol index cdadbc2..45a1dc3 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -3,33 +3,63 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; +import {DeployCandidate} from "../src/abstract/RainDeploySuitesBase.sol"; import {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../src/lib/LibRainDeploySnapshot.sol"; -import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; + +/// One contract's generated files: the rolling snapshot, the alias lib that +/// re-exports its pins and the released-suites lib emitted from its record. +/// +/// The candidate carries the creation code the snapshot is written from and the +/// declaration metadata the released lib copies, so the only things a generated +/// contract adds to it are the two names codegen needs. +struct GeneratedContract { + /// The contract's name, which places its snapshot inside + /// `src/generated//` and names both generated libs. + string contractName; + /// The prefix for the constants the alias lib exports, e.g. + /// `ADDRESS_REGISTRY`. Passed rather than derived; see `writeAliasLib`. + string constantPrefix; + /// The rolling candidate from the declaration. Its `sourceCreationCode` is + /// what the snapshot is generated from, and its `snapshot` is the template + /// the released lib takes its key, artifact path and dependencies from. + DeployCandidate candidate; +} /// @title Build -/// @notice Generates the deterministic-deploy pins for `AddressRegistry`. +/// @notice Generates the deterministic-deploy pins for every contract this repo +/// deploys. /// /// Two entry points, because there are two different things to do and only one /// of them happens on an ordinary build: /// -/// - `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. +/// - `run()` — every build. Regenerates the ROLLING snapshots under +/// `src/generated/candidate/` from current source, and the alias libs that +/// point at them. Nothing here is frozen, so a source change simply moves it. +/// - `cutRelease()` — a release. Regenerates the rolling snapshots and freezes +/// them as `src/generated//`, in ONE call, in that order. +/// +/// The alias libs always point at `candidate`, so consumers' import paths never +/// move and `LibAddressRegistry` and `LibMigrationRegistry` always resolve +/// against what this repo currently compiles. The frozen `/` directories +/// are the historical record — what each release actually deployed — which is +/// what `RegistryDeploySuites.releasedSuites()` enumerates. /// -/// 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 -/// `RegistryDeploySuites.releasedSuites()` enumerates. +/// BOTH entry points also regenerate the released-suites libs from the record. +/// `run()` must: they are imported by ordinary source, so a repo before its +/// first release still has to have them, and with nothing frozen they declare +/// an empty set. /// -/// 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. +/// ## One list, three readers +/// +/// `generatedContracts()` is the whole of what this script declares, and the +/// regeneration, the lib writers and the freeze all read it. A contract added +/// to it is generated, aliased, released and frozen; there is no second list to +/// add it to and therefore no way to add it to one and not the other. That +/// matters most for the freeze: a contract regenerated but left out of the +/// names `freeze` is given is a contract silently absent from the release, +/// which nothing downstream can notice, because a tag that never held it has +/// nothing missing from it. /// /// The metadata each released entry carries beyond its frozen snapshot comes /// from the named candidate on the declaration, which is why this inherits the @@ -37,34 +67,38 @@ import {AddressRegistry} from "../src/concrete/AddressRegistry.sol"; /// path and one dependency list per contract in this repo, and a second copy of /// them here is a second copy that drifts. /// -/// Named rather than indexed out of `candidateSuites()`: a released-suites lib -/// describes ONE contract, so this has to select the candidate for the contract -/// it is writing, and a positional read would silently write another contract's -/// metadata the moment the list is reordered. +/// Candidates are reached by NAME rather than by index into `candidateSuites()` +/// for the same reason: a released-suites lib describes one contract, so a +/// positional read would silently write another contract's metadata the moment +/// the list is reordered. /// /// 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, RegistryDeploySuites { - /// @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 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"; + /// Every contract this repo generates deploy pins for, declared ONCE. + /// @return contracts The generated contracts. + function generatedContracts() internal pure returns (GeneratedContract[] memory contracts) { + contracts = new GeneratedContract[](2); + contracts[0] = GeneratedContract({ + contractName: "AddressRegistry", constantPrefix: "ADDRESS_REGISTRY", candidate: addressRegistryCandidate() + }); + contracts[1] = GeneratedContract({ + contractName: "MigrationRegistry", + constantPrefix: "MIGRATION_REGISTRY", + candidate: migrationRegistryCandidate() + }); + } - /// @notice Every build: regenerate the rolling snapshot, its alias lib and - /// the released-suites lib. + /// @notice Every build: regenerate the rolling snapshots, their alias libs + /// and the released-suites libs. function run() external { - regenerateCandidate(); + regenerateCandidates(); regenerateLibs(); } - /// @notice A release: regenerate the rolling snapshot, freeze it as this + /// @notice A release: regenerate the rolling snapshots, freeze them as this /// release's immutable record, then regenerate the declaration of that /// record. /// @@ -73,32 +107,46 @@ contract Build is Script, RegistryDeploySuites { /// the regeneration and runs it FIRST; there is no entry point that freezes /// without regenerating, so a stale freeze has nowhere to come from. /// - /// 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. + /// The released-suites libs are written from the record AFTER the freeze, + /// so the release being cut is in them. 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] = CONTRACT_NAME; - LibRainDeploySnapshot.freeze(vm, regenerateCandidate, contractNames); + GeneratedContract[] memory contracts = generatedContracts(); + string[] memory contractNames = new string[](contracts.length); + for (uint256 i = 0; i < contracts.length; i++) { + contractNames[i] = contracts[i].contractName; + } + LibRainDeploySnapshot.freeze(vm, regenerateCandidates, contractNames); 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. + /// @notice Rewrite every alias lib and every 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, CONTRACT_NAME, addressRegistryCandidate().snapshot - ); + GeneratedContract[] memory contracts = generatedContracts(); + for (uint256 i = 0; i < contracts.length; i++) { + LibRainDeploySnapshot.writeAliasLib( + vm, contracts[i].contractName, contracts[i].constantPrefix, LibRainDeploySnapshot.CANDIDATE + ); + LibRainDeploySnapshot.writeReleasedSuitesLib( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, contracts[i].contractName, contracts[i].candidate.snapshot + ); + } } - /// @notice Rewrite `src/generated/candidate/AddressRegistry.sol` from what - /// this repo currently compiles. - function regenerateCandidate() internal { - LibRainDeploySnapshot.writeSnapshot( - vm, LibRainDeploySnapshot.CANDIDATE, CONTRACT_NAME, type(AddressRegistry).creationCode - ); + /// @notice Rewrite every `src/generated/candidate/` snapshot from what this + /// repo currently compiles. + function regenerateCandidates() internal { + GeneratedContract[] memory contracts = generatedContracts(); + for (uint256 i = 0; i < contracts.length; i++) { + LibRainDeploySnapshot.writeSnapshot( + vm, + LibRainDeploySnapshot.CANDIDATE, + contracts[i].contractName, + contracts[i].candidate.sourceCreationCode + ); + } } } diff --git a/script/Deploy.sol b/script/Deploy.sol index ed1f371..0370711 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -7,7 +7,8 @@ import {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; /// @title Deploy /// @notice The on-chain deploy. Broadcasts whichever suite `DEPLOYMENT_SUITE` -/// names, through the Zoltu factory, to every supported network. +/// names, through the Zoltu factory, to every supported network. One suite per +/// dispatch, so this repo's two registries are two dispatches. /// /// Empty on purpose. The suites come from `RegistryDeploySuites`, which /// is the same declaration the verification tests inherit, and the dispatch, @@ -27,7 +28,9 @@ import {RegistryDeploySuites} from "../src/abstract/RegistryDeploySuites.sol"; /// chains of five, one RPC down — is fixed by running it again rather than by /// unpicking anything. /// -/// `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. +/// `RegistryDeployChainTest` is what says whether this has been run and worked +/// — but only for RELEASED suites, and this repo has released none, so today it +/// has nothing to check and passes. It gets a subject once a release is frozen, +/// and then fails until every supported network has that release's code, which +/// is why the deploy comes before the tag rather than after it. contract Deploy is RegistryDeploySuites, RainDeployBroadcast {} diff --git a/src/abstract/RegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol index 5453883..a5d40ae 100644 --- a/src/abstract/RegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -4,12 +4,19 @@ pragma solidity ^0.8.25; import {DeployCandidate, DeploySuite, RainDeploySuitesBase} from "./RainDeploySuitesBase.sol"; import {AddressRegistry} from "../concrete/AddressRegistry.sol"; +import {MigrationRegistry} from "../concrete/MigrationRegistry.sol"; import { CREATION_CODE as ADDRESS_REGISTRY_CREATION_CODE_CANDIDATE, RUNTIME_CODE as ADDRESS_REGISTRY_RUNTIME_CODE_CANDIDATE } from "../generated/candidate/AddressRegistry.sol"; +import { + CREATION_CODE as MIGRATION_REGISTRY_CREATION_CODE_CANDIDATE, + RUNTIME_CODE as MIGRATION_REGISTRY_RUNTIME_CODE_CANDIDATE +} from "../generated/candidate/MigrationRegistry.sol"; import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol"; +import {LibMigrationRegistryDeploy} from "../lib/LibMigrationRegistryDeploy.sol"; +import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased.sol"; /// @title RegistryDeploySuites /// @notice Everything this repo deploys, declared ONCE. @@ -17,9 +24,8 @@ import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol" /// Three contracts inherit this and nothing else declares a suite: /// /// - `script/Deploy.sol` broadcasts from it -/// - `AddressRegistryDeploySnapshotTest` checks its records against its -/// creation code -/// - `AddressRegistryDeployChainTest` checks it against every chain +/// - `RegistryDeploySnapshotTest` checks its records against its creation code +/// - `RegistryDeployChainTest` 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 @@ -35,9 +41,9 @@ import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol" /// are all inherited. There is deliberately nothing per suite beyond an array /// entry and nothing per network at all. /// -/// 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. +/// Half of it is written by hand and half is generated: the candidates below +/// are the declaration, and the released libs come from `script/Build.sol`, +/// which emits them from the frozen record. abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase /// @dev Generated by `script/Build.sol` from the frozen @@ -47,26 +53,42 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// 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 LibAddressRegistryReleased.releasedSuites(); + /// One generated lib per deployed contract, concatenated here, because + /// `writeReleasedSuitesLib` emits the releases of ONE contract: a release + /// freezes every contract it names into a single tag directory, so a lib + /// that took the record whole would give another contract's snapshot this + /// contract's suite key and collide with its own entry for that tag. + /// + /// Both are empty until the first release is cut. The rolling `candidate/` + /// snapshots are not releases and do exist. + function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { + DeploySuite[] memory addressRegistry = LibAddressRegistryReleased.releasedSuites(); + DeploySuite[] memory migrationRegistry = LibMigrationRegistryReleased.releasedSuites(); + + suites = new DeploySuite[](addressRegistry.length + migrationRegistry.length); + for (uint256 i = 0; i < addressRegistry.length; i++) { + suites[i] = addressRegistry[i]; + } + for (uint256 i = 0; i < migrationRegistry.length; i++) { + suites[addressRegistry.length + i] = migrationRegistry[i]; + } } /// @inheritdoc RainDeploySuitesBase - /// @dev One entry, because this repo deploys one contract. A second - /// deployed contract is a second named candidate below, a second entry - /// here, and a second snapshot in `script/Build.sol` — nothing else. + /// @dev One entry per contract this repo deploys. A third deployed contract + /// is a third named candidate below, a third entry here, and a third entry + /// in `script/Build.sol`'s generated-contract list — nothing else. function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { - candidates = new DeployCandidate[](1); + candidates = new DeployCandidate[](2); candidates[0] = addressRegistryCandidate(); + candidates[1] = migrationRegistryCandidate(); } /// This repo's rolling `AddressRegistry` candidate. /// - /// 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 pins in `LibAddressRegistryDeploy` are aliased from the generated + /// snapshot, 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 are RECORDED, read from the rolling /// `src/generated/candidate/` snapshot. That is what makes the source @@ -99,4 +121,32 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase { sourceCreationCode: type(AddressRegistry).creationCode }); } + + /// This repo's rolling `MigrationRegistry` candidate. + /// + /// Everything said about the `AddressRegistry` candidate holds here + /// unchanged: the pins are aliased from the generated snapshot, the + /// creation and runtime code are recorded rather than derived, and the + /// source anchor is what says the record describes THIS contract. + /// + /// `MigrationRegistry` has no constructor argument, no compile-time + /// authority and no dependency to be on chain first — the namespace is + /// `msg.sender`, so there is nothing to configure and nothing to resolve. + /// That is also why it is deployable to a new network the day the network + /// is added, with no follow-up transaction to make it useful. + /// @return The candidate. + function migrationRegistryCandidate() internal pure returns (DeployCandidate memory) { + return DeployCandidate({ + snapshot: DeploySuite({ + suite: "migration-registry", + creationCode: MIGRATION_REGISTRY_CREATION_CODE_CANDIDATE, + storedDeployedAddress: LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, + storedBytecodeHash: LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + storedRuntimeCode: MIGRATION_REGISTRY_RUNTIME_CODE_CANDIDATE, + artifactPath: "src/concrete/MigrationRegistry.sol:MigrationRegistry", + dependencies: new address[](0) + }), + sourceCreationCode: type(MigrationRegistry).creationCode + }); + } } diff --git a/src/generated/candidate/MigrationRegistry.sol b/src/generated/candidate/MigrationRegistry.sol new file mode 100644 index 0000000..9c4a0a8 --- /dev/null +++ b/src/generated/candidate/MigrationRegistry.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +/// @dev Hash of the known bytecode. +bytes32 constant BYTECODE_HASH = bytes32(0xf709539b20f5664f5c5d62c2a1da01720c558aa7bcd1b2ebbc02e23d3efd7860); + +/// @dev The deterministic deploy address of the contract when deployed via +/// the Zoltu factory. +address constant DEPLOYED_ADDRESS = address(0x49Ec15571a99087762a343C12Ff98AEE4F576Aa3); + +/// @dev The creation bytecode of the contract. +bytes constant CREATION_CODE = + hex"6080604052348015600e575f80fd5b506102898061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c8063b5c645bd14610038578063e29304161461004d575b5f80fd5b61004b610046366004610230565b610074565b005b61006061005b366004610247565b610175565b604051901515815260200160405180910390f35b806100ab576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f9081526020818152604080832084845290915290205460ff161561010a576040517f09ffc7000000000000000000000000000000000000000000000000000000000081523360048201526024810182905260440160405180910390fd5b335f8181526020818152604080832085845290915280822080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055518392917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a350565b5f73ffffffffffffffffffffffffffffffffffffffff83166101c3576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b816101fa576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205460ff1690565b5f60208284031215610240575f80fd5b5035919050565b5f8060408385031215610258575f80fd5b823573ffffffffffffffffffffffffffffffffffffffff8116811461027b575f80fd5b94602093909301359350505056"; + +/// @dev The runtime bytecode of the contract. +bytes constant RUNTIME_CODE = + hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c8063b5c645bd14610038578063e29304161461004d575b5f80fd5b61004b610046366004610230565b610074565b005b61006061005b366004610247565b610175565b604051901515815260200160405180910390f35b806100ab576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f9081526020818152604080832084845290915290205460ff161561010a576040517f09ffc7000000000000000000000000000000000000000000000000000000000081523360048201526024810182905260440160405180910390fd5b335f8181526020818152604080832085845290915280822080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055518392917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a350565b5f73ffffffffffffffffffffffffffffffffffffffff83166101c3576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b816101fa576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205460ff1690565b5f60208284031215610240575f80fd5b5035919050565b5f8060408385031215610258575f80fd5b823573ffffffffffffffffffffffffffffffffffffffff8116811461027b575f80fd5b94602093909301359350505056"; diff --git a/src/lib/LibMigrationRegistry.sol b/src/lib/LibMigrationRegistry.sol new file mode 100644 index 0000000..2851ce0 --- /dev/null +++ b/src/lib/LibMigrationRegistry.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {IMigrationRegistryV1} from "../interface/IMigrationRegistryV1.sol"; +import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; + +/// @title LibMigrationRegistry +/// @notice Reads and writes the `MigrationRegistry` deployed at a single +/// deterministic address on every network, verifying the registry's code hash +/// first, exactly as `LibAddressRegistry` does for the address registry and +/// `LibRainDeploy` does for 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 answers whether a writer has recorded a +/// migration, and it records one under the caller. Which writer a test trusts, +/// which invariant each answer selects, and how an id is derived are entirely +/// the consumer's business and none of this library's. +/// +/// ## There is deliberately no broadcast runner here +/// +/// `LibRainDeploy` wraps broadcasting because a deploy is always a broadcast. +/// A migration is not: the dominant real shape is a Safe executing a bundle, +/// where the script emits transactions for the multisig to sign and never +/// broadcasts anything itself. Such a script appends `record` to the bundle it +/// is already emitting, which is what makes the record atomic with the +/// migration it describes — a property no runner in this library could offer, +/// and one a runner would quietly compete with. +/// +/// So `record` is an ordinary call. A broadcasting EOA script wraps it in its +/// own `vm.startBroadcast`, a Safe bundle appends it, and a test calls it +/// directly; none of those is privileged over the others here. +/// +/// ## Reading is what this is for +/// +/// A test asserts EXACTLY the value implied by the migrations that have run: +/// +/// ```solidity +/// if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2)) { +/// assertEq(vault.owner(), NEW_OWNER); +/// } else { +/// assertEq(vault.owner(), OLD_OWNER); +/// } +/// ``` +/// +/// Both branches assert. Neither reads the clock, neither skips, and the branch +/// is selected by what happened on chain rather than by a deadline somebody +/// guessed. `applied` answering `false` is an ordinary, expected answer — it is +/// the state of every migration before it runs and of every migration on a +/// chain that never got it — which is why the registry answers it rather than +/// reverting. +/// +/// The registry is an INDEX, not proof. It says which invariant applies; it does +/// not say the invariant holds. A multisig can act out of band and nothing here +/// moves. Codehash and bytecode pins are what verify the state itself, and this +/// library is not a substitute for them. +library LibMigrationRegistry { + /// 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, never the expected value. + /// @param expectedCodeHash The code hash of the pinned registry. + /// @param actualCodeHash The code hash actually found at the address. + error UnexpectedMigrationRegistryCodeHash(bytes32 expectedCodeHash, bytes32 actualCodeHash); + + /// Reverts unless the pinned registry address holds the pinned code. + /// + /// Both entry points check, and they check the same way, because both are + /// worse than useless against unknown code: a read would branch a test on + /// whatever that code returned, and a write would record a migration + /// somewhere nothing will ever read it. The check is one function so the + /// two cannot drift into checking different things. + function checkCodeHash() internal view { + bytes32 actualCodeHash = LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.codehash; + if (actualCodeHash != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH) { + revert UnexpectedMigrationRegistryCodeHash( + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, actualCodeHash + ); + } + } + + /// Whether `writer` has recorded `migration`. + /// + /// 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. That distinction is + /// the whole point here: "no registry on this chain" and "this migration + /// has not been applied" are different facts, and silently collapsing the + /// first into the second would send a caller down its pre-migration branch + /// on every chain the registry was never deployed to. + /// + /// The registry itself refuses the zero writer and the zero migration, so + /// those arrive as reverts from it rather than as `false`. + /// @param writer The namespace to read — the authority whose record the + /// caller trusts. Never the zero address. + /// @param migration The migration to ask about. Never zero. + /// @return Whether `writer` has recorded `migration`. + function applied(address writer, bytes32 migration) internal view returns (bool) { + checkCodeHash(); + return + IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS) + .applied(writer, migration); + } + + /// Records `migration` under the CALLER's namespace. + /// + /// The caller is whoever the resulting transaction is sent from — a Safe + /// executing a bundle, a broadcasting EOA, a timelock — and that account is + /// the namespace the record lands in. A reader has to ask about that same + /// account, so which account a migration is recorded from is a decision + /// with a consequence rather than an implementation detail. + /// + /// Verifies the registry's code hash before writing, so a migration is + /// never "recorded" into an empty address or into unknown code. A record + /// that went nowhere is worse than no record at all: the migration would + /// have run, and every reader would go on asserting the pre-migration + /// state. + /// + /// The registry refuses the zero id and refuses a migration this caller has + /// already recorded, which is what makes a re-dispatched migration fail + /// rather than repeat. + /// @param migration The migration to record. Never zero. + function record(bytes32 migration) internal { + checkCodeHash(); + IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS).record(migration); + } +} diff --git a/src/lib/LibMigrationRegistryDeploy.sol b/src/lib/LibMigrationRegistryDeploy.sol new file mode 100644 index 0000000..e49cd71 --- /dev/null +++ b/src/lib/LibMigrationRegistryDeploy.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import { + DEPLOYED_ADDRESS as MIGRATION_REGISTRY_ADDR, + BYTECODE_HASH as MIGRATION_REGISTRY_HASH +} from "../generated/candidate/MigrationRegistry.sol"; + +/// @title LibMigrationRegistryDeploy +/// @notice The deterministic Zoltu deploy address and code hash of +/// `MigrationRegistry`, 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 LibMigrationRegistryDeploy { + address constant MIGRATION_REGISTRY_DEPLOYED_ADDRESS = MIGRATION_REGISTRY_ADDR; + bytes32 constant MIGRATION_REGISTRY_DEPLOYED_CODEHASH = MIGRATION_REGISTRY_HASH; +} diff --git a/src/lib/LibMigrationRegistryReleased.sol b/src/lib/LibMigrationRegistryReleased.sol new file mode 100644 index 0000000..e648118 --- /dev/null +++ b/src/lib/LibMigrationRegistryReleased.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 LibMigrationRegistryReleased +/// @notice Every frozen release of `MigrationRegistry`: 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 LibMigrationRegistryReleased { + /// 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/test/concrete/MockMigrationRecorder.sol b/test/concrete/MockMigrationRecorder.sol new file mode 100644 index 0000000..3f9da38 --- /dev/null +++ b/test/concrete/MockMigrationRecorder.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibMigrationRegistry} from "../../src/lib/LibMigrationRegistry.sol"; + +/// @title MockMigrationRecorder +/// @notice A consumer in the shape `LibMigrationRegistry.record` is designed +/// for: it calls the library and nothing else, so the record lands under THIS +/// contract's address. +/// +/// It exists so the namespace can be exercised as the property it is. The +/// library's functions are `internal` and inline into whatever executes them, +/// so `msg.sender` at the registry is the calling CONTRACT, not whoever +/// `vm.prank` last named — a test contract calling the library directly can +/// therefore only ever write one namespace. Two of these are two namespaces, +/// which is what makes "a record reaches nobody else" checkable rather than +/// asserted about a single account. +contract MockMigrationRecorder { + /// Records `migration` under this contract. + /// @param migration The migration to record. + function record(bytes32 migration) external { + LibMigrationRegistry.record(migration); + } + + /// Whether `writer` has recorded `migration`. + /// @param writer The namespace to read. + /// @param migration The migration to ask about. + /// @return Whether it is recorded. + function applied(address writer, bytes32 migration) external view returns (bool) { + return LibMigrationRegistry.applied(writer, migration); + } +} diff --git a/test/src/abstract/RegistryDeployChain.t.sol b/test/src/abstract/RegistryDeployChain.t.sol index d0015d9..fcd24af 100644 --- a/test/src/abstract/RegistryDeployChain.t.sol +++ b/test/src/abstract/RegistryDeployChain.t.sol @@ -5,23 +5,25 @@ pragma solidity =0.8.25; import {RainDeployVerifyChain} from "../../../src/abstract/RainDeployVerifyChain.sol"; import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; -/// @title AddressRegistryDeployChainTest -/// @notice Whether `AddressRegistry` is actually live, with the code this repo -/// compiles, on every supported network. +/// @title RegistryDeployChainTest +/// @notice Whether every registry this repo has RELEASED is actually live, with +/// the code that release froze, 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 every supported network. +/// It has released none. The chain group reads `releasedSuites()`, which is +/// empty until the first release is cut, so there is nothing here to check: this +/// forks nothing and passes. It gets a subject the moment a release is frozen +/// and declared, and from then on it is red until that release is live on every +/// supported network — which is why the deploy is dispatched before the tag is +/// pushed. /// -/// 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 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. +/// That eventual failure is the check working. "Nothing is deployed at the +/// address `LibAddressRegistry` reads" is true today, it is the single most +/// important fact 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. /// -/// It is a separate contract from `AddressRegistryDeploySnapshotTest` precisely -/// so that it says this and nothing more: a missing deployment or an -/// unreachable endpoint fails here alone, leaving every snapshot assertion to -/// answer for itself. -contract AddressRegistryDeployChainTest is RegistryDeploySuites, RainDeployVerifyChain {} +/// It is a separate contract from `RegistryDeploySnapshotTest` precisely so that +/// it says this and nothing more: a missing deployment or an unreachable +/// endpoint fails here alone, leaving every snapshot assertion to answer for +/// itself. +contract RegistryDeployChainTest is RegistryDeploySuites, RainDeployVerifyChain {} diff --git a/test/src/abstract/RegistryDeploySnapshot.t.sol b/test/src/abstract/RegistryDeploySnapshot.t.sol index b1be36b..326a54b 100644 --- a/test/src/abstract/RegistryDeploySnapshot.t.sol +++ b/test/src/abstract/RegistryDeploySnapshot.t.sol @@ -5,19 +5,20 @@ pragma solidity =0.8.25; import {RainDeployVerifySnapshot} from "../../../src/abstract/RainDeployVerifySnapshot.sol"; import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; -/// @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 +/// @title RegistryDeploySnapshotTest +/// @notice The deploy-pin assertions for every registry this repo deploys that +/// need no network: what each alias lib records is what the creation code this +/// repo compiles derives, and each candidate is a snapshot of its own 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 +/// of this repo's compiler settings — and the contracts, 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. +/// across a boundary. `AddressRegistry`'s root authority is a constant in its +/// creation code, so changing the root moves its pins and turns this red until +/// they follow. /// /// Both assertions are inherited. There is nothing to write here, which is the /// point: `RegistryDeploySuites` says which versions exist and /// `RainDeployVerifySnapshot` says what is true of them. -contract AddressRegistryDeploySnapshotTest is RegistryDeploySuites, RainDeployVerifySnapshot {} +contract RegistryDeploySnapshotTest is RegistryDeploySuites, RainDeployVerifySnapshot {} diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index b7aecec..b722246 100644 --- a/test/src/lib/GeneratedSnapshotShape.t.sol +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -2,7 +2,9 @@ // 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 {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; +import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; +import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; /// @title GeneratedSnapshotShapeTest /// @notice What a generated deploy snapshot must look like, asserted against @@ -23,18 +25,52 @@ 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 `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"; +/// +/// EVERY rolling snapshot is checked, not the first one. The generator writes +/// one per contract this repo deploys, so a shape asserted about one of them is +/// a shape asserted about the generator only for as long as there is one; the +/// second contract is exactly where a hand edit lands unseen. The set comes +/// from the directory the generator writes to and is checked to be the size the +/// declaration implies, because a walk that found nothing is a loop that passes +/// while asserting nothing at all. +contract GeneratedSnapshotShapeTest is RegistryDeploySuites, Test { + /// The generated-file header every snapshot carries. + string constant GENERATED_HEADER = "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."; + + /// Every rolling snapshot the generator has written, by contract name. + /// + /// Read from the directory rather than listed here, so a contract added to + /// the declaration is covered by these assertions without anybody + /// remembering to add it — which is the failure mode a shape spec is least + /// able to notice, since a file nobody checks looks exactly like a file + /// that passes. + /// @return names One entry per snapshot, e.g. `AddressRegistry`. + function snapshotContractNames() internal view returns (string[] memory names) { + Vm.DirEntry[] memory entries = vm.readDir(LibRainDeploySnapshot.dirForSnapshot(LibRainDeploySnapshot.CANDIDATE)); + + names = new string[](entries.length); + for (uint256 i = 0; i < entries.length; i++) { + string[] memory components = vm.split(entries[i].path, "/"); + names[i] = vm.replace(components[components.length - 1], ".sol", ""); + } + } + + /// The artifact for one generated snapshot, which carries its AST. + /// @param contractName The contract the snapshot describes. + /// @return The artifact path. + function artifactPath(string memory contractName) internal pure returns (string memory) { + return string.concat("out/", LibRainDeploySnapshot.CANDIDATE, "/", contractName, ".sol/", contractName, ".json"); + } - /// The node types of the generated snapshot's source unit, in file order. + /// The node types of a 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. + /// @param contractName The contract the snapshot describes. /// @return types One entry per top-level AST node. - function nodeTypes() internal view returns (string[] memory types) { - string memory json = vm.readFile(ARTIFACT); + function nodeTypes(string memory contractName) internal view returns (string[] memory types) { + string memory json = vm.readFile(artifactPath(contractName)); string[] memory found = new string[](64); uint256 count = 0; while (vm.keyExistsJson(json, string.concat("$.ast.nodes[", vm.toString(count), "].nodeType"))) { @@ -47,12 +83,13 @@ contract GeneratedSnapshotShapeTest is Test { } } - /// The declared constants of the generated snapshot, in file order, as + /// The declared constants of a generated snapshot, in file order, as /// ` ` — read from the AST, so formatting cannot affect it. + /// @param contractName The contract the snapshot describes. /// @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(); + function constantDeclarations(string memory contractName) internal view returns (string[] memory declarations) { + string memory json = vm.readFile(artifactPath(contractName)); + string[] memory types = nodeTypes(contractName); string[] memory found = new string[](types.length); uint256 count = 0; @@ -75,50 +112,79 @@ contract GeneratedSnapshotShapeTest is Test { } } - /// PROPERTY: the snapshot declares exactly the four constants a deploy + /// PROPERTY: there is one rolling snapshot per declared candidate. + /// + /// Every other assertion here loops over the snapshots the walk found, and + /// a loop over a short list passes. This is what says the list is not + /// short: a candidate with no rolling snapshot is a contract whose + /// generated shape nothing below checks, and a snapshot with no candidate + /// is a record of something this repo no longer declares. + function testEveryCandidateHasASnapshot() external view { + assertEq( + snapshotContractNames().length, + checkedCandidateSuites().length, + "a candidate has no rolling snapshot, or a snapshot has no candidate" + ); + } + + /// PROPERTY: every 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(); + string[] memory contractNames = snapshotContractNames(); + for (uint256 i = 0; i < contractNames.length; i++) { + string[] memory declarations = constantDeclarations(contractNames[i]); - 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"); + 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 snapshot imports nothing. It is read by repos that do not + /// PROPERTY: a 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"); + string[] memory contractNames = snapshotContractNames(); + for (uint256 i = 0; i < contractNames.length; i++) { + string[] memory types = nodeTypes(contractNames[i]); + for (uint256 j = 0; j < types.length; j++) { + assertNotEq(types[j], "ImportDirective", "snapshot imports something"); + } } } - /// PROPERTY: the snapshot declares no contract, library or interface. It is + /// PROPERTY: a 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"); + string[] memory contractNames = snapshotContractNames(); + for (uint256 i = 0; i < contractNames.length; i++) { + string[] memory types = nodeTypes(contractNames[i]); + for (uint256 j = 0; j < types.length; j++) { + assertNotEq(types[j], "ContractDefinition", "snapshot declares a contract"); + } } } - /// PROPERTY: the snapshot says it is generated. Someone who opens it must + /// PROPERTY: a 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( - 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" - ); + string[] memory contractNames = snapshotContractNames(); + for (uint256 i = 0; i < contractNames.length; i++) { + assertTrue( + vm.contains( + vm.readFile( + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.CANDIDATE, contractNames[i]) + ), + GENERATED_HEADER + ), + "snapshot is missing the generated-file header" + ); + } } } diff --git a/test/src/lib/LibMigrationRegistry.t.sol b/test/src/lib/LibMigrationRegistry.t.sol new file mode 100644 index 0000000..e20b453 --- /dev/null +++ b/test/src/lib/LibMigrationRegistry.t.sol @@ -0,0 +1,223 @@ +// 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 {LibMigrationRegistry} from "../../../src/lib/LibMigrationRegistry.sol"; +import {LibMigrationRegistryDeploy} from "../../../src/lib/LibMigrationRegistryDeploy.sol"; +import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; +import {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; +import {MockMigrationRecorder} from "../../concrete/MockMigrationRecorder.sol"; + +/// @title LibMigrationRegistryTest +/// Tests for `LibMigrationRegistry`. The registry is not mocked: the real +/// `MigrationRegistry` 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 functions so `vm.expectRevert` +/// lands at the correct call depth. +contract LibMigrationRegistryTest is Test { + /// Deploys `MigrationRegistry` through the Zoltu factory, which lands it at + /// the pinned address. + /// @return The deployed registry. + function deployRegistry() internal returns (IMigrationRegistryV1) { + LibRainDeploy.etchZoltuFactory(vm); + return IMigrationRegistryV1(LibRainDeploy.deployZoltu(type(MigrationRegistry).creationCode)); + } + + /// External wrapper for `applied` so that `vm.expectRevert` works at the + /// correct call depth. + /// @param writer The namespace to read. + /// @param migration The migration to ask about. + /// @return Whether `writer` has recorded `migration`. + function externalApplied(address writer, bytes32 migration) external view returns (bool) { + return LibMigrationRegistry.applied(writer, migration); + } + + /// External wrapper for `record` so that `vm.expectRevert` works at the + /// correct call depth. + /// @param migration The migration to record. + function externalRecord(bytes32 migration) external { + LibMigrationRegistry.record(migration); + } + + /// The Zoltu deploy really does land the registry on its pinned address + /// with its pinned code hash. Every other test here depends on that, and a + /// pin that had gone stale would otherwise show up as an unrelated + /// code-hash revert in all of them. + function testDeployMatchesPins() external { + deployRegistry(); + + assertEq( + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.codehash, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH + ); + } + + /// An unrecorded migration answers `false`. This is the branch a caller + /// asserts the pre-migration state in, and it is the ordinary state of + /// every migration that has not run, so it is an answer rather than a + /// revert. + function testAppliedUnrecordedIsFalse(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(migration != bytes32(0)); + deployRegistry(); + + assertFalse(LibMigrationRegistry.applied(writer, migration)); + } + + /// A recorded migration answers `true` — read back through the library, so + /// what `record` writes is what `applied` finds. + function testRecordThenApplied(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + deployRegistry(); + + LibMigrationRegistry.record(migration); + + assertTrue(LibMigrationRegistry.applied(address(this), migration)); + } + + /// The namespace is the CONTRACT that executes the library call. The + /// library's functions are `internal`, so they inline into their caller and + /// the registry sees that caller as `msg.sender` — which means a consumer + /// chooses its namespace by choosing what sends the transaction, and cannot + /// write anybody else's. + function testRecordLandsUnderTheCallingContract(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + deployRegistry(); + MockMigrationRecorder recorder = new MockMigrationRecorder(); + + recorder.record(migration); + + assertTrue(LibMigrationRegistry.applied(address(recorder), migration)); + assertFalse(LibMigrationRegistry.applied(address(this), migration)); + } + + /// One caller's record reaches no other namespace, and each answers only + /// for itself. This is the whole of the access control: a reader's choice + /// of writer is the whole of who it trusts. + function testRecordDoesNotReachAnotherNamespace(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + deployRegistry(); + MockMigrationRecorder recorder = new MockMigrationRecorder(); + MockMigrationRecorder other = new MockMigrationRecorder(); + + recorder.record(migration); + + assertTrue(other.applied(address(recorder), migration)); + assertFalse(other.applied(address(other), migration)); + } + + /// Recording the same migration twice is refused, and the registry's own + /// revert arrives unmodified — the library adds no handling of its own, so + /// a re-dispatched migration fails naming the writer and the id. + function testRecordTwiceReverts(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + deployRegistry(); + + LibMigrationRegistry.record(migration); + + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, address(this), migration) + ); + this.externalRecord(migration); + } + + /// The registry's zero-id refusal arrives unmodified through `record`. + function testRecordZeroMigrationReverts() external { + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + this.externalRecord(bytes32(0)); + } + + /// The registry's zero-writer refusal arrives unmodified through `applied`. + function testAppliedZeroWriterReverts(bytes32 migration) external { + vm.assume(migration != bytes32(0)); + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + this.externalApplied(address(0), migration); + } + + /// The registry's zero-id refusal arrives unmodified through `applied`. + function testAppliedZeroMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); + this.externalApplied(writer, bytes32(0)); + } + + /// A chain with no registry deployed reverts on the code hash rather than + /// calling into an empty account. That call would succeed and return + /// nothing, which `abi.decode` would read as `false` — "this migration has + /// not been applied", on every chain the registry was never deployed to, + /// which is exactly the silent pre-migration branch this library exists to + /// make impossible. + function testAppliedNoRegistry(address writer, bytes32 migration) external { + assertEq(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + bytes32(0) + ) + ); + this.externalApplied(writer, migration); + } + + /// Writing to a chain with no registry is refused for the mirror reason: a + /// `record` into an empty account is a migration that reports itself + /// recorded and is not, which leaves every reader asserting the + /// pre-migration state forever. + function testRecordNoRegistry(bytes32 migration) external { + assertEq(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + bytes32(0) + ) + ); + this.externalRecord(migration); + } + + /// A chain where something other than the pinned registry occupies the + /// address reverts on the code hash, so a migration is never read from code + /// the caller did not compile against. + function testAppliedWrongCode(address writer, bytes32 migration, bytes memory code) external { + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH); + vm.etch(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, code); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + keccak256(code) + ) + ); + this.externalApplied(writer, migration); + } + + /// And never recorded into it either. + function testRecordWrongCode(bytes32 migration, bytes memory code) external { + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH); + vm.etch(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, code); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + keccak256(code) + ) + ); + this.externalRecord(migration); + } +} From f23911cddc19bde05aa70efe3c465c4742619c92 Mon Sep 17 00:00:00 2001 From: David Meister Date: Fri, 14 Aug 2026 18:39:39 +0000 Subject: [PATCH 06/11] test(snapshot): match the rolling snapshots to the candidates by name `testEveryCandidateHasASnapshot` compared only the two counts. Equal counts are also what a candidate whose snapshot is missing and an unrelated stale snapshot produce together, which is the pair a walk of a generated directory is most likely to find -- so the assertion could hold while every other assertion in the file looped over somebody else's files. The declared candidates and the walked directory are now matched as sets, by the contract name each candidate's `:` artifact path already carries. Both directions, because a candidate with no snapshot and a snapshot with no candidate are different faults, and the counts stay asserted so two candidates naming one contract cannot pass by both matching its one snapshot. Co-Authored-By: Claude Opus 5 (1M context) --- test/src/lib/GeneratedSnapshotShape.t.sol | 76 +++++++++++++++++++---- 1 file changed, 65 insertions(+), 11 deletions(-) diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index b722246..0b6ed27 100644 --- a/test/src/lib/GeneratedSnapshotShape.t.sol +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -3,6 +3,7 @@ pragma solidity =0.8.25; import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; +import {DeployCandidate} from "../../../src/abstract/RainDeploySuitesBase.sol"; import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; @@ -30,9 +31,9 @@ import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol" /// one per contract this repo deploys, so a shape asserted about one of them is /// a shape asserted about the generator only for as long as there is one; the /// second contract is exactly where a hand edit lands unseen. The set comes -/// from the directory the generator writes to and is checked to be the size the -/// declaration implies, because a walk that found nothing is a loop that passes -/// while asserting nothing at all. +/// from the directory the generator writes to and is checked to be the same set +/// the declaration names, because a walk that found the wrong files is a loop +/// that passes while asserting nothing about the contracts this repo deploys. contract GeneratedSnapshotShapeTest is RegistryDeploySuites, Test { /// The generated-file header every snapshot carries. string constant GENERATED_HEADER = "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND."; @@ -112,19 +113,72 @@ contract GeneratedSnapshotShapeTest is RegistryDeploySuites, Test { } } - /// PROPERTY: there is one rolling snapshot per declared candidate. + /// The contract a candidate describes, taken from the `:` + /// artifact path it declares. + /// + /// That name is also the name of the candidate's rolling snapshot: the + /// generator writes `src/generated/candidate/.sol`, and the + /// declaration imports that exact file for its recorded creation code. A + /// declaration and a generator that disagree about the name are a + /// declaration whose snapshot is somebody else's. + /// @param candidate The candidate to name. + /// @return The contract name. + function candidateContractName(DeployCandidate memory candidate) internal view returns (string memory) { + string[] memory components = vm.split(candidate.snapshot.artifactPath, ":"); + return components[components.length - 1]; + } + + /// Whether `names` holds `name`. + /// @param names The names to search. + /// @param name The name to find. + /// @return Whether it is present. + function holdsName(string[] memory names, string memory name) internal pure returns (bool) { + for (uint256 i = 0; i < names.length; i++) { + if (keccak256(bytes(names[i])) == keccak256(bytes(name))) { + return true; + } + } + return false; + } + + /// PROPERTY: the rolling snapshots and the declared candidates are the same + /// set, matched by name. /// /// Every other assertion here loops over the snapshots the walk found, and - /// a loop over a short list passes. This is what says the list is not - /// short: a candidate with no rolling snapshot is a contract whose - /// generated shape nothing below checks, and a snapshot with no candidate - /// is a record of something this repo no longer declares. + /// a loop over a short list passes. This is what says the list is neither + /// short nor somebody else's: a candidate with no rolling snapshot is a + /// contract whose generated shape nothing below checks, and a snapshot with + /// no candidate is a record of something this repo no longer declares. + /// + /// Matched by name in both directions rather than by counting, because + /// equal counts are also what a missing snapshot and a stale one left + /// behind together produce, and that is the pair a walk of a directory is + /// most likely to find. The counts are asserted as well, so two candidates + /// naming one contract cannot pass by both matching its single snapshot. function testEveryCandidateHasASnapshot() external view { + string[] memory snapshots = snapshotContractNames(); + DeployCandidate[] memory candidates = checkedCandidateSuites(); + + string[] memory declared = new string[](candidates.length); + for (uint256 i = 0; i < candidates.length; i++) { + declared[i] = candidateContractName(candidates[i]); + } + assertEq( - snapshotContractNames().length, - checkedCandidateSuites().length, - "a candidate has no rolling snapshot, or a snapshot has no candidate" + snapshots.length, declared.length, "a candidate has no rolling snapshot, or a snapshot has no candidate" ); + + for (uint256 i = 0; i < declared.length; i++) { + assertTrue( + holdsName(snapshots, declared[i]), string.concat("candidate has no rolling snapshot: ", declared[i]) + ); + } + + for (uint256 i = 0; i < snapshots.length; i++) { + assertTrue( + holdsName(declared, snapshots[i]), string.concat("rolling snapshot has no candidate: ", snapshots[i]) + ); + } } /// PROPERTY: every snapshot declares exactly the four constants a deploy From e314b961d0923c339da8814bd026754be9b259bd Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 06:47:11 +0000 Subject: [PATCH 07/11] feat(migration-registry): timestamped applied, per-writer head, genesis `applied` returns a uint256 timestamp instead of a bool; zero means not recorded. `record` reverts `ZeroTimestamp()` rather than writing a record that reads back as no record. `record` takes an `expectedHead` and reverts `UnexpectedMigrationHead(writer, expectedHead, actualHead)` on mismatch. A successful record makes the migration the writer's new head, readable via `head(address writer)`. The head chain starts at `MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.head.genesis")`, deliberately non-zero so an uninitialised predecessor constant cannot be a successful first record. Genesis is a head, not a migration: both `record` and `applied` revert `GenesisMigration()` for it, alongside the existing zero-id refusal. The duplicate guard survives as `sApplied[...] != 0`. Regenerates the candidate snapshot for the new creation code. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 42 +- README.md | 78 +++- src/concrete/MigrationRegistry.sol | 139 ++++-- src/generated/candidate/MigrationRegistry.sol | 8 +- src/interface/IMigrationRegistryV1.sol | 243 ++++++++-- src/lib/LibMigrationRegistry.sol | 85 +++- test/concrete/MockMigrationRecorder.sol | 24 +- .../concrete/MigrationRegistryApplied.t.sol | 99 ++++- test/src/concrete/MigrationRegistryHead.t.sol | 162 +++++++ .../concrete/MigrationRegistryRecord.t.sol | 416 +++++++++++++++--- test/src/lib/LibMigrationRegistry.t.sol | 185 ++++++-- 11 files changed, 1260 insertions(+), 221 deletions(-) create mode 100644 test/src/concrete/MigrationRegistryHead.t.sol diff --git a/CLAUDE.md b/CLAUDE.md index 8b16ac8..857b309 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,9 +128,11 @@ against it by `RegistryDeploySnapshotTest`. GENERATED by `script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. **`src/interface/IMigrationRegistryV1.sol`** — the migration registry interface: -a writer records one of its own migrations (`record`), and anyone reads whether -a given writer has recorded a given migration (`applied`). There is no removal, -no upgrade and no authority beyond the writer over its own namespace. +a writer records one of its own migrations onto the head it believes its +namespace is at (`record`), anyone reads when a given writer recorded a given +migration (`applied`), and anyone reads where that writer's namespace is +(`head`). There is no removal, no upgrade and no authority beyond the writer +over its own namespace. It exists so a prod-state test decides what to assert by reading what happened on chain rather than by reading the clock. Without it, a test that spans a @@ -143,9 +145,33 @@ bytecode pins are what say the invariant holds. A multisig can act out of band, so replacing the pins with this would trade a clock-guess for a bookkeeping guess. -**`src/concrete/MigrationRegistry.sol`** — the implementation. Two functions and -nothing else, and — unlike `AddressRegistry` — no compile-time constant of any -kind. +`applied` answers a TIMESTAMP, and zero still means "not recorded". Time-shaped +invariants — a cliff, a rate change, a grace period — need the moment as well as +the fact, and a flag sends them back to the hardcoded date. Zero stays +unambiguous because `record` refuses to write in a block whose timestamp is +zero, rather than write a record that reads back as no record. + +The HEAD is what makes a sequence ordered. `record` names the migration it is +applying onto and refuses to write unless the namespace is there, so a skipped +predecessor and an out-of-order concurrent dispatch both fail at apply time +instead of diverging silently; the recorded migration becomes the new head. +`MIGRATION_HEAD_GENESIS` is the head of a namespace that has recorded nothing, +and it is deliberately NOT zero: a zero genesis would make an uninitialised +predecessor constant a successful first record on any empty namespace, which is +the state of every chain not yet migrated. It is not a valid migration id +either, for the same reason a head must mean one thing. + +The head does not replace the per-migration refusal. Re-recording a migration +whose successor has landed presents a matching head, and without +`MigrationAlreadyRecorded` would drag the head backwards and overwrite the +original timestamp. One namespace on one chain is one linear sequence, so two +independent sequences want two writer accounts. + +**`src/concrete/MigrationRegistry.sol`** — the implementation. Three functions +and nothing else, and — unlike `AddressRegistry` — nothing CONFIGURED at compile +time. `MIGRATION_HEAD_GENESIS` is a compile-time constant, but it is one value +for every consumer on every chain and names nobody, so it cannot fragment the +deterministic address the way a root would. The namespace is `msg.sender`, which is the whole access control. A root would have to be welded into the creation code, as `ADDRESS_REGISTRY_ROOT` is, and the @@ -160,8 +186,8 @@ With nothing to configure there is no rollout state in which it is inert: it does its whole job the moment it exists on a chain, which is the opposite of `AddressRegistry` under a zero root. -**`src/lib/LibMigrationRegistry.sol`** — the consumer surface: `applied` and -`record`, both verifying the registry's code hash first, exactly as +**`src/lib/LibMigrationRegistry.sol`** — the consumer surface: `applied`, `head` +and `record`, all verifying the registry's code hash first, exactly as `LibAddressRegistry.resolve` does. There is deliberately no broadcast runner: the dominant real migration shape is a Safe executing a bundle that never broadcasts, and such a script appends `record` to the bundle it is already diff --git a/README.md b/README.md index 43a59b5..2c32ba7 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,11 @@ It answers: deployment? - Is every version I have ever released still live, with the code I compiled, on every network I support? -- Which operational migrations have actually been applied on this chain, so a - test can assert the state they imply instead of guessing from a date? +- Which operational migrations have actually been applied on this chain, and + when, so a test can assert the state they imply instead of guessing from a + date? +- Can a migration be skipped, repeated or applied out of order on one chain and + not another? Approach: @@ -36,7 +39,8 @@ Approach: and a post-deploy check that every target network's deployment took the address it was supposed to. - A migration registry, so operational scripts record what they applied and - tests assert the state that implies rather than branching on a deadline. + when, each onto the head it is applying to, and tests assert the state that + implies rather than branching on a deadline. - One inherited deploy-pin verification, parameterized over versions, rather than assertions hand-enumerated per version and per chain in every deploy repo. @@ -180,9 +184,11 @@ library supplies the fork loop and the comparison. ## Migration registry -`MigrationRegistry` records that a migration has been applied: a writer records -one of its own (`record`), and anyone reads whether a given writer has recorded -a given one (`applied`). There is no removal and no upgrade. +`MigrationRegistry` records that a migration has been applied, and when: a +writer records one of its own onto the migration it believes ran last +(`record`), anyone reads when a given writer recorded a given one (`applied`), +and anyone reads where a given writer's sequence has got to (`head`). There is +no removal and no upgrade. It exists because prod-state tests otherwise decide what to assert by reading the **clock**. The pattern that emerges without it is a dual-state invariant — @@ -194,24 +200,64 @@ rather than on a fact. What you actually want is "**exactly** the value implied by the migrations that have run", which needs the chain to hold which ones have: ```solidity -if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2)) { +if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2) != 0) { assertEq(vault.owner(), NEW_OWNER); } else { assertEq(vault.owner(), OLD_OWNER); } ``` -Both branches assert exactly. Neither skips, and `applied` answering `false` is -an ordinary expected answer rather than a revert — it is the state of every +Both branches assert exactly. Neither skips, and `applied` answering zero is an +ordinary expected answer rather than a revert — it is the state of every migration before it runs, and of every migration on a chain that never got it. +**`applied` is a timestamp, not a flag.** "Which invariant applies" is +frequently "which invariant applies _yet_": a cliff that starts at the +migration, a rate that changes a week after it. A flag sends a consumer that +needs the moment back to a hardcoded date, which is the thing this registry +exists to delete. Zero and nonzero carry the same two distinct facts a flag did, +with the nonzero case saying more — and zero stays unambiguous because a record +is refused outright in a block whose timestamp is zero rather than written as +one that reads back as no record. + **A set of applied migrations, not a high-water mark.** A mark needs a total order consumers do not have: two migrations authored on one day collide, and one migration split across two scripts because it landed on two networks a week apart cannot be one comparable value at all. A set represents both exactly, and the ordering between migrations moves into the assertion — -`applied(V5) ? … : applied(V4) ? … : …` — which is where the semantic dependency -actually lives. +`applied(V5) != 0 ? … : applied(V4) != 0 ? … : …` — which is where the semantic +dependency actually lives. + +**A head, so a step cannot be skipped or repeated.** A namespace has a head: the +migration it recorded most recently, or `MIGRATION_HEAD_GENESIS` if it has +recorded none. `record` names the head it is applying onto, so a chain that +never got the predecessor fails at the moment of applying rather than diverging +silently, and two migrations dispatched at once cannot land in the wrong order. + +```solidity +// The first migration in a namespace. +LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, MIGRATION_V1); +// Every later one names its predecessor. +LibMigrationRegistry.record(MIGRATION_V1, MIGRATION_V2); +``` + +Genesis is deliberately **not zero**. Zero is what an uninitialised `bytes32` +constant reads as, and a zero genesis would make a mis-set predecessor constant +a _successful_ first record on any namespace that happens to be empty — the +state of every chain that has not been migrated yet, which is exactly where such +a mistake is most likely. A nonzero genesis makes it a revert everywhere. + +The head does **not** replace the per-migration refusal, and both are kept. +Re-recording a migration whose successor has landed names a head that matches +perfectly; without `MigrationAlreadyRecorded` it would drag the head backwards +and overwrite the original timestamp, which is a record un-happening. The two +answer different questions — the head is _where in the sequence_, the record is +_whether at all_. + +One namespace on one chain is therefore one linear sequence. Two unrelated sets +of migrations applied from the same account interleave into one chain of heads, +so a consumer that wants two independent sequences records them from two +accounts — the same lever that already decides who a reader trusts. **The namespace is `msg.sender`, and that is the whole access control.** Anyone may write, but only under themselves, so a reader asking about the namespace of @@ -229,11 +275,11 @@ say the invariant holds — a multisig can act out of band and nothing here move Keep both layers: this selects, codehash and bytecode pins verify. Replacing the pins with it trades a clock-guess for a bookkeeping-guess. -`LibMigrationRegistry` is the surface — `applied` and `record`, both verifying -the registry's code hash first. There is deliberately **no broadcast runner**: -the dominant real shape is a Safe executing a bundle that never broadcasts, and -such a script appends `record` to the bundle it is already emitting, which makes -the record atomic with the migration it describes. +`LibMigrationRegistry` is the surface — `applied`, `head` and `record`, all +verifying the registry's code hash first. There is deliberately **no broadcast +runner**: the dominant real shape is a Safe executing a bundle that never +broadcasts, and such a script appends `record` to the bundle it is already +emitting, which makes the record atomic with the migration it describes. ## Deploying, and then releasing diff --git a/src/concrete/MigrationRegistry.sol b/src/concrete/MigrationRegistry.sol index ba2ea0c..3f22b57 100644 --- a/src/concrete/MigrationRegistry.sol +++ b/src/concrete/MigrationRegistry.sol @@ -2,16 +2,20 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {IMigrationRegistryV1} from "../interface/IMigrationRegistryV1.sol"; +import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../interface/IMigrationRegistryV1.sol"; /// @title MigrationRegistry /// @notice The whole of `IMigrationRegistryV1`: a writer records one of its own -/// migrations, and anyone reads whether a given writer has recorded a given -/// migration. +/// migrations onto the head it believes its namespace is at, and anyone reads +/// when a given writer recorded a given migration, or where that writer's +/// namespace has got to. /// /// There is deliberately nothing else. No removal, no upgrade, no pause, and no /// authority at all — which is the difference from `AddressRegistry`, and the -/// reason this contract has no compile-time constant of any kind. +/// reason nothing here is CONFIGURED at compile time. `MIGRATION_HEAD_GENESIS` +/// is a compile-time constant, but it is the same value for every consumer on +/// every chain and names nobody, so it is part of what this contract IS rather +/// than a choice welded into it. /// /// `AddressRegistry` has a root, and a root has to be welded into the creation /// code so it cannot be rotated, which puts it in the deterministic address. @@ -30,51 +34,132 @@ import {IMigrationRegistryV1} from "../interface/IMigrationRegistryV1.sol"; /// in which this contract is inert: it does its whole job the moment it exists /// on a chain. /// -/// A record is append-only per writer. `record` refuses a migration the caller -/// has already recorded, which is what makes re-running a migration fail rather -/// than repeat, and there is no way to unrecord one — a record describes -/// something that happened, and nothing that happened stops having happened. +/// A record is append-only per writer, and a head only ever moves forward onto +/// something new. `record` refuses a migration the caller has already recorded, +/// which is what makes re-running a migration fail rather than repeat, and +/// refuses one applied onto anything but the namespace's current head, which is +/// what makes a skipped or out-of-order migration fail rather than diverge. +/// There is no way to unrecord one — a record describes something that happened, +/// and nothing that happened stops having happened. /// -/// The storage mapping is `internal` rather than `public`: `applied` refuses -/// the zero writer and the zero migration, and a public mapping's generated -/// getter would answer both with `false`, which is exactly the silent -/// wrong-branch this contract reverts to prevent. +/// Neither storage mapping is `public`. `applied` and `head` refuse the zero +/// writer, `applied` refuses the two ids a migration can never be, and a public +/// mapping's generated getter would answer all of them with zero — which for +/// `applied` is "not recorded" and for `head` is a value no head can ever hold, +/// i.e. exactly the silent wrong-branch this contract reverts to prevent. contract MigrationRegistry is IMigrationRegistryV1 { - /// The records, namespaced by writer. Not `public`: the only reader is - /// `applied`, which refuses the two inputs that can only be mistakes. - mapping(address writer => mapping(bytes32 migration => bool recorded)) internal sApplied; + /// When each record landed, namespaced by writer. Zero means never. Not + /// `public`: the only reader is `applied`, which refuses the two inputs that + /// can only be mistakes. + mapping(address writer => mapping(bytes32 migration => uint256 appliedAt)) internal sApplied; + + /// The most recent migration recorded under each writer. Zero means the + /// namespace is empty, which reads out as `MIGRATION_HEAD_GENESIS` — the + /// only place that translation happens is `readHead`, so no reader and no + /// writer can disagree about where an empty namespace is. Not `public`, for + /// the same reason as the records: the untranslated zero is not a head. + mapping(address writer => bytes32 head) internal sHead; + + /// The head of `writer`'s namespace, with an empty namespace translated to + /// genesis. One function, because `record` compares against it and `head` + /// returns it, and the two cannot be allowed to drift into different ideas + /// of where a namespace that has recorded nothing is. + /// @param writer The namespace to read. + /// @return The head. Never zero. + function readHead(address writer) internal view returns (bytes32) { + bytes32 recordedHead = sHead[writer]; + return recordedHead == bytes32(0) ? MIGRATION_HEAD_GENESIS : recordedHead; + } /// @inheritdoc IMigrationRegistryV1 - function record(bytes32 migration) external { - // Checked before the already-recorded read, so an uninitialised id is - // reported as the mistake it is rather than as a first record of zero. + /// @dev The refusals run caller-input first and environment last: the two + /// that describe a mistake in the call are true whatever block this lands + /// in, so they are what a caller is told about first. + function record(bytes32 expectedHead, bytes32 migration) external { + // Checked before everything else, so an uninitialised id is reported as + // the mistake it is rather than as a first record of zero. if (migration == bytes32(0)) { revert ZeroMigration(); } + // Genesis is a head, not a migration. Recording it would leave `sHead` + // holding the value an empty namespace reads as, so a namespace that had + // recorded something would be at a head indistinguishable from one that + // had recorded nothing — and the next first-migration script would be + // accepted against it. + if (migration == MIGRATION_HEAD_GENESIS) { + revert GenesisMigration(); + } // There is deliberately no zero-writer case here. `msg.sender` cannot // be the zero address, so the zero namespace is unreachable for writes // and a guard on it would be unreachable code pretending to be a check. - if (sApplied[msg.sender][migration]) { + // Nor is there a zero-head case: a head is either genesis or a recorded + // id, both nonzero, so a zero `expectedHead` can never match and is + // already refused below, by an error that names the zero it was handed. + + // Checked before the head, because a migration that has already run has + // already run whatever the head is, and that is the more useful thing to + // say to a re-dispatched script. It is also not implied by the head + // check: re-recording a migration whose successor has landed presents a + // matching head, and would drag the head backwards and overwrite the + // original timestamp. + if (sApplied[msg.sender][migration] != 0) { revert MigrationAlreadyRecorded(msg.sender, migration); } - sApplied[msg.sender][migration] = true; + bytes32 actualHead = readHead(msg.sender); + if (expectedHead != actualHead) { + revert UnexpectedMigrationHead(msg.sender, expectedHead, actualHead); + } + // A zero timestamp is the one value a record cannot carry: `applied` + // would answer it as "never recorded" while the head had moved and the + // migration could never be recorded again. Not unreachable — a test can + // warp to zero and a chain can be configured from a zero genesis — so + // this is a real check rather than a decorative one. + // + // The usual hazard behind a `block.timestamp` comparison — the one both + // the static analysers flag here — is a validator nudging the clock + // across a threshold. There is no threshold here and no nudge + // available: zero is not a value a validator on a live chain can + // produce at all, which is why this is an equality against it rather + // than a window around it, and why the two warnings are suppressed on + // this line alone rather than turned off for the repo. + // slither-disable-next-line incorrect-equality,timestamp + if (block.timestamp == 0) { // forge-lint: disable-line(block-timestamp) + revert ZeroTimestamp(); + } + sApplied[msg.sender][migration] = block.timestamp; + sHead[msg.sender] = migration; emit Migrated(msg.sender, migration); } /// @inheritdoc IMigrationRegistryV1 - /// @dev Both refusals are about a caller that has not supplied what it - /// thinks it has. Neither can ever be a real record: nothing originates - /// from the zero address, and `record` will not write the zero id — so - /// answering `false` for either would be answering a question the caller - /// did not mean to ask, and answering it with the value that sends it down - /// its pre-migration branch. - function applied(address writer, bytes32 migration) external view returns (bool) { + /// @dev All three refusals are about a caller that has not supplied what it + /// thinks it has. None can ever be a real record: nothing originates from + /// the zero address, and `record` will write neither the zero id nor the + /// genesis one — so answering zero for any of them would be answering a + /// question the caller did not mean to ask, and answering it with the value + /// that sends it down its pre-migration branch. + function applied(address writer, bytes32 migration) external view returns (uint256) { if (writer == address(0)) { revert ZeroWriter(); } if (migration == bytes32(0)) { revert ZeroMigration(); } + if (migration == MIGRATION_HEAD_GENESIS) { + revert GenesisMigration(); + } return sApplied[writer][migration]; } + + /// @inheritdoc IMigrationRegistryV1 + /// @dev The zero namespace is refused rather than answered `genesis`: it is + /// provably empty forever, so "a namespace nothing has been applied to" is a + /// true statement about it and a false one about what the caller meant to + /// ask, which would send a first migration at it. + function head(address writer) external view returns (bytes32) { + if (writer == address(0)) { + revert ZeroWriter(); + } + return readHead(writer); + } } diff --git a/src/generated/candidate/MigrationRegistry.sol b/src/generated/candidate/MigrationRegistry.sol index 9c4a0a8..341a19a 100644 --- a/src/generated/candidate/MigrationRegistry.sol +++ b/src/generated/candidate/MigrationRegistry.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(0xf709539b20f5664f5c5d62c2a1da01720c558aa7bcd1b2ebbc02e23d3efd7860); +bytes32 constant BYTECODE_HASH = bytes32(0x6ec72be90febf29858c6ed3725ce83ac069b4144535c8893bd3812ec01ec147f); /// @dev The deterministic deploy address of the contract when deployed via /// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0x49Ec15571a99087762a343C12Ff98AEE4F576Aa3); +address constant DEPLOYED_ADDRESS = address(0xD30aB9571a185e5c883C7b0e30f4D5560009BaFC); /// @dev The creation bytecode of the contract. bytes constant CREATION_CODE = - hex"6080604052348015600e575f80fd5b506102898061001c5f395ff3fe608060405234801561000f575f80fd5b5060043610610034575f3560e01c8063b5c645bd14610038578063e29304161461004d575b5f80fd5b61004b610046366004610230565b610074565b005b61006061005b366004610247565b610175565b604051901515815260200160405180910390f35b806100ab576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f9081526020818152604080832084845290915290205460ff161561010a576040517f09ffc7000000000000000000000000000000000000000000000000000000000081523360048201526024810182905260440160405180910390fd5b335f8181526020818152604080832085845290915280822080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055518392917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a350565b5f73ffffffffffffffffffffffffffffffffffffffff83166101c3576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b816101fa576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205460ff1690565b5f60208284031215610240575f80fd5b5035919050565b5f8060408385031215610258575f80fd5b823573ffffffffffffffffffffffffffffffffffffffff8116811461027b575f80fd5b94602093909301359350505056"; + hex"6080604052348015600e575f80fd5b506104b08061001c5f395ff3fe608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063bda4fec514610043578063cda1c3b914610068578063e29304161461007d575b5f80fd5b61005661005136600461044f565b610090565b60405190815260200160405180910390f35b61007b610076366004610468565b6100ed565b005b61005661008b366004610488565b6102bc565b5f73ffffffffffffffffffffffffffffffffffffffff82166100de576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100e7826103cd565b92915050565b80610124576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f810361017d576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f90815260208181526040808320848452909152902054156101da576040517f09ffc700000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101e4336103cd565b905080831461022f576040517facbe685200000000000000000000000000000000000000000000000000000000815233600482015260248101849052604481018290526064016101d1565b425f03610268576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff831661030a576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b81610341576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f820361039a576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526001602052604081205480156103fe5780610420565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff8116811461044a575f80fd5b919050565b5f6020828403121561045f575f80fd5b61042082610427565b5f8060408385031215610479575f80fd5b50508035926020909101359150565b5f8060408385031215610499575f80fd5b6104a283610427565b94602093909301359350505056"; /// @dev The runtime bytecode of the contract. bytes constant RUNTIME_CODE = - hex"608060405234801561000f575f80fd5b5060043610610034575f3560e01c8063b5c645bd14610038578063e29304161461004d575b5f80fd5b61004b610046366004610230565b610074565b005b61006061005b366004610247565b610175565b604051901515815260200160405180910390f35b806100ab576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f9081526020818152604080832084845290915290205460ff161561010a576040517f09ffc7000000000000000000000000000000000000000000000000000000000081523360048201526024810182905260440160405180910390fd5b335f8181526020818152604080832085845290915280822080547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00166001179055518392917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a350565b5f73ffffffffffffffffffffffffffffffffffffffff83166101c3576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b816101fa576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205460ff1690565b5f60208284031215610240575f80fd5b5035919050565b5f8060408385031215610258575f80fd5b823573ffffffffffffffffffffffffffffffffffffffff8116811461027b575f80fd5b94602093909301359350505056"; + hex"608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063bda4fec514610043578063cda1c3b914610068578063e29304161461007d575b5f80fd5b61005661005136600461044f565b610090565b60405190815260200160405180910390f35b61007b610076366004610468565b6100ed565b005b61005661008b366004610488565b6102bc565b5f73ffffffffffffffffffffffffffffffffffffffff82166100de576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100e7826103cd565b92915050565b80610124576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f810361017d576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f90815260208181526040808320848452909152902054156101da576040517f09ffc700000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101e4336103cd565b905080831461022f576040517facbe685200000000000000000000000000000000000000000000000000000000815233600482015260248101849052604481018290526064016101d1565b425f03610268576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff831661030a576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b81610341576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f820361039a576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526001602052604081205480156103fe5780610420565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff8116811461044a575f80fd5b919050565b5f6020828403121561045f575f80fd5b61042082610427565b5f8060408385031215610479575f80fd5b50508035926020909101359150565b5f8060408385031215610499575f80fd5b6104a283610427565b94602093909301359350505056"; diff --git a/src/interface/IMigrationRegistryV1.sol b/src/interface/IMigrationRegistryV1.sol index b47fa76..7d72d97 100644 --- a/src/interface/IMigrationRegistryV1.sol +++ b/src/interface/IMigrationRegistryV1.sol @@ -2,13 +2,40 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; +/// @dev The head of a namespace that has never recorded a migration. A writer's +/// first `record` names this, and every later one names the migration before it. +/// +/// It is deliberately NOT zero. Zero is what an uninitialised `bytes32` constant +/// reads as, and a genesis of zero would make an uninitialised predecessor +/// constant a SUCCESSFUL first record on any namespace that happens to be empty +/// — which is the state of every namespace on every chain the consumer has not +/// migrated yet, i.e. exactly where a mis-set constant is most likely and most +/// expensive. Under a nonzero genesis that same constant is a revert in every +/// namespace state, empty or not, for the same reason `ZeroMigration` and +/// `ZeroWriter` exist: an uninitialised value is a mistake to be reported, never +/// a question to be answered. +/// +/// It is one shared value rather than anything derived per writer or per +/// consumer, so it configures nothing and cannot fragment the implementation's +/// deterministic address. +/// +/// It is not a migration, and an implementation MUST refuse it as one. A head +/// holds exactly two kinds of value: a recorded migration, or this. Letting a +/// migration BE this would put a namespace that has recorded something at a head +/// indistinguishable from one that has recorded nothing, which is the same +/// collapse of two distinct facts into one value that `ZeroMigration` exists to +/// refuse — the two values a head can hold that are not migrations are exactly +/// the two values a migration id may not be. +bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.head.genesis"); + /// @title IMigrationRegistryV1 -/// @notice A per-writer record of which migrations have been applied, with -/// exactly two operations: a writer records one of its own migrations -/// (`record`), and anyone reads whether a given writer has recorded a given -/// migration (`applied`). There is no removal, no upgrade and no authority -/// beyond the writer over its own namespace, and an implementation MUST NOT add -/// any. +/// @notice A per-writer record of which migrations have been applied and when, +/// with exactly three operations: a writer records one of its own migrations +/// onto the head it believes its namespace is at (`record`), anyone reads when a +/// given writer recorded a given migration (`applied`), and anyone reads where a +/// given writer's namespace currently is (`head`). There is no removal, no +/// upgrade and no authority beyond the writer over its own namespace, and an +/// implementation MUST NOT add any. /// /// It exists so that a test can decide what to assert by reading what happened /// on chain rather than by reading the clock. Without it, a test that spans a @@ -30,7 +57,67 @@ pragma solidity ^0.8.25; /// actually holds. Replacing the pins with this registry trades a clock-guess /// for a bookkeeping-guess, which is not an improvement. An implementation MUST /// NOT offer anything that invites it, and in particular MUST NOT record -/// anything about the state a migration produced — only that it was recorded. +/// anything about the state a migration produced — only that it was recorded, +/// and when. +/// +/// The timestamp is a fact about the RECORD, not about the state: it is the +/// block the record landed in, which the record's own log already carries, and +/// it says nothing whatsoever about what the migration did. Reading it back does +/// not become proof of anything, for the same reason reading the record back +/// does not. +/// +/// ## `applied` answers WHEN, and zero still means "not recorded" +/// +/// `applied` is a timestamp rather than a flag because "which invariant applies" +/// is frequently "which invariant applies YET": a migration that starts a +/// vesting cliff, a rate change, a grace period. A flag forces a consumer that +/// needs the moment to go and find the log for it, or — worse — to go back to +/// the deadline constant this registry exists to delete. +/// +/// A migration nobody recorded answers zero, and that is an ANSWER rather than a +/// revert: it is the ordinary state of every migration before it runs and of +/// every migration on a chain that never got it, and it is the branch a caller +/// asserts the pre-migration state in. Zero and nonzero are therefore the same +/// two distinct facts a flag carried, with the nonzero case saying more. +/// +/// That distinction is only sound while a real record can never BE zero, so an +/// implementation MUST refuse to write a record in a block whose timestamp is +/// zero rather than write one that reads back as no record at all. That is not a +/// hypothetical branch: a test can `vm.warp(0)`, and a chain can be configured +/// from a zero genesis. +/// +/// ## The head is what makes an ordered sequence ordered +/// +/// A namespace has a HEAD: the migration most recently recorded under it, or +/// `MIGRATION_HEAD_GENESIS` if it has never recorded one. `record` takes the +/// head the caller believes its namespace is at and refuses to write unless that +/// is where the namespace actually is; on success the recorded migration becomes +/// the new head. +/// +/// This is what blocks a SKIPPED step. A migration script names its predecessor, +/// so a chain that never got the predecessor is a loud revert at the moment of +/// applying rather than a namespace that silently diverges from every other +/// chain's. It is equally what blocks two migrations dispatched concurrently +/// from landing in whichever order the mempool chose: the second one names a +/// head that has moved. +/// +/// It does NOT block a DUPLICATE, and `MigrationAlreadyRecorded` is not +/// redundant beside it. Re-recording a migration whose successor has since +/// landed presents a head that matches perfectly, and would move the head +/// BACKWARDS and overwrite the original timestamp — a record un-happening, which +/// is the one thing this registry promises cannot occur. The two refusals answer +/// two different questions: the head is about WHERE in the sequence a caller is, +/// and the already-recorded refusal is about WHETHER this particular migration +/// has run at all. +/// +/// One namespace on one chain is therefore ONE linear sequence, and that is a +/// consequence to design around rather than an implementation detail. Two +/// unrelated sets of migrations applied from the same account on the same chain +/// interleave into one chain of heads, so each script's expected head is +/// whatever that account last recorded rather than whatever that script's own +/// author had in mind. A consumer that wants two independent sequences records +/// them from two accounts, which is the same lever that already decides who a +/// reader trusts. /// /// ## The namespace is the writer, and that is the whole access control /// @@ -57,8 +144,12 @@ pragma solidity ^0.8.25; /// /// A migration is an opaque 32-byte value. This interface says nothing about /// how one is derived — hashed from a script path, a name, a counter — and an -/// implementation MUST NOT constrain it. Two callers agreeing on an id is -/// entirely their business. +/// implementation MUST NOT constrain it beyond the two values the head space +/// reserves: zero, which is what an empty namespace holds before it is read as +/// genesis, and `MIGRATION_HEAD_GENESIS`, which is what it reads as. Neither can +/// be a migration without a head losing the ability to say whether a namespace +/// has recorded anything. Two callers agreeing on any other id is entirely their +/// business. /// /// The convention that suits scripts-as-migrations is the hash of the script's /// identity, e.g. `keccak256("script/20260623-upgrade-receipt-vaults.s.sol")`. @@ -67,20 +158,43 @@ pragma solidity ^0.8.25; /// recorded, so a script renamed afterwards keeps the id it was recorded under /// rather than acquiring a new one — which is why the id belongs in a named /// constant beside the script, not derived from a path at the call site. +/// +/// A head is an id, so the same is true of the head a script names: it is the +/// predecessor's named constant, imported, not a second spelling of it. interface IMigrationRegistryV1 { /// Thrown when `record` is called with the zero migration id, and by /// `applied` when it is asked about one. The zero id is what an /// uninitialised `bytes32` constant reads as, and an uninitialised id is /// never a migration anybody meant to name. Rejected in both directions - /// because the read is the dangerous one: answering `false` would silently + /// because the read is the dangerous one: answering zero would silently /// send a caller down its pre-migration branch. + /// + /// There is no matching refusal for a zero HEAD, and adding one would be a + /// guard on something already impossible: a head is either + /// `MIGRATION_HEAD_GENESIS` or a recorded id, both nonzero, so a zero head + /// can never match and is already refused by `UnexpectedMigrationHead` — + /// which names the zero it was handed, so nothing about the mistake is lost. error ZeroMigration(); - /// Thrown by `applied` when asked about the zero writer. No transaction can - /// originate from the zero address, so the zero namespace is provably empty - /// and the answer would always be `false` — an unresolved or unset writer - /// constant would therefore read as "nothing has been applied" rather than - /// as the mistake it is. + /// Thrown when `record` is called with `MIGRATION_HEAD_GENESIS` as the + /// migration, and by `applied` when it is asked about it. Genesis is a head, + /// not a migration: recording it would leave a namespace that has recorded + /// something at a head no different from one that has recorded nothing, and + /// asking `applied` about it would answer zero forever for a caller that has + /// confused a head for a migration and will read that as its pre-migration + /// branch. + /// + /// This is the same refusal as `ZeroMigration` under a different diagnosis, + /// and they are separate errors because the mistakes are different: a zero + /// is a constant nobody set, and this is a constant set to the wrong one of + /// two that sit beside each other. + error GenesisMigration(); + + /// Thrown by `applied` and `head` when asked about the zero writer. No + /// transaction can originate from the zero address, so the zero namespace is + /// provably empty and the answer would always be "nothing recorded, at + /// genesis" — an unresolved or unset writer constant would therefore read as + /// a pristine namespace rather than as the mistake it is. /// /// There is no matching case on `record`: `msg.sender` is never zero, so /// the zero namespace cannot be written to in the first place. @@ -91,23 +205,59 @@ interface IMigrationRegistryV1 { /// than a warning in a workflow dropdown asking a human not to re-dispatch /// it: a script consults `applied` before it acts, and this is the backstop /// under that consultation. + /// + /// Checked BEFORE the head, because it is the more specific true statement + /// about the call and it is true whatever the head is. A re-dispatched + /// script is told the migration already ran, rather than told the namespace + /// has moved on and left to work out why. /// @param writer The namespace, which is the caller. /// @param migration The migration already recorded under it. error MigrationAlreadyRecorded(address writer, bytes32 migration); + /// Thrown when a writer records onto a head its namespace is not at. Either + /// something the caller believed had been applied has not been, or something + /// it did not know about has been — a skipped predecessor, a concurrent + /// dispatch that landed first, or a chain that is simply further behind than + /// the script assumed. + /// @param writer The namespace, which is the caller. + /// @param expectedHead The head the caller said it was applying onto. + /// @param actualHead The head the namespace is actually at. + error UnexpectedMigrationHead(address writer, bytes32 expectedHead, bytes32 actualHead); + + /// Thrown when `record` is called in a block whose timestamp is zero. A + /// record IS its timestamp, so a zero one would read back through `applied` + /// as no record at all, while the head moved and the migration cannot be + /// re-recorded — the worst of every branch at once. Refusing to write is the + /// only outcome that leaves the namespace describing something true. + error ZeroTimestamp(); + /// Emitted every time a migration is recorded. A migration is recorded at /// most once per writer, so the log is the complete history of the registry /// and the only way to discover a record without already knowing the id. + /// + /// It carries neither the head nor the timestamp because both are already + /// there: the log is ordered, and one writer's entries in order ARE that + /// writer's chain of heads — each entry's migration is the head the next one + /// was applied onto, and the first was applied onto + /// `MIGRATION_HEAD_GENESIS`. The timestamp is the block's. /// @param writer The namespace, which is the caller. /// @param migration The migration recorded. event Migrated(address indexed writer, bytes32 indexed migration); - /// Records `migration` as applied under the caller's namespace. + /// Records `migration` as applied under the caller's namespace, onto + /// `expectedHead`. /// /// The implementation MUST revert `ZeroMigration` if `migration` is zero, - /// MUST revert `MigrationAlreadyRecorded` if the caller has already - /// recorded it, and MUST NOT provide any way to unrecord one. On success it - /// MUST emit `Migrated`. + /// `GenesisMigration` if it is `MIGRATION_HEAD_GENESIS`, + /// `MigrationAlreadyRecorded` if the caller has already recorded it, + /// `UnexpectedMigrationHead` if the caller's namespace is not at + /// `expectedHead`, and `ZeroTimestamp` if `block.timestamp` is zero. It MUST + /// NOT provide any way to unrecord a migration or to move a head backwards. + /// On success it MUST record the current block timestamp against + /// `migration`, make `migration` the caller's new head, and emit `Migrated`. + /// + /// Nothing is returned: the new head is the `migration` just passed in and + /// the timestamp is the block's, so both are already in the caller's hand. /// /// A caller SHOULD record the migration in the same atomic unit as the /// migration itself where it can — a Safe appends this call to the bundle @@ -117,25 +267,52 @@ interface IMigrationRegistryV1 { /// which the verification layer then catches loudly, and leaves a re-run /// possible. A record that landed for a migration that did not is the /// harder state to get out of. - /// @param migration The migration to record. - function record(bytes32 migration) external; + /// @param expectedHead The head the caller believes its namespace is at: + /// the migration it is applying onto, or `MIGRATION_HEAD_GENESIS` for the + /// first migration in a namespace. Never zero, which can never match. + /// @param migration The migration to record. Never zero, never + /// `MIGRATION_HEAD_GENESIS`. + function record(bytes32 expectedHead, bytes32 migration) external; - /// Whether `writer` has recorded `migration`. + /// When `writer` recorded `migration`, as the timestamp of the block the + /// record landed in. Zero if it never did. /// - /// The implementation MUST revert `ZeroWriter` or `ZeroMigration` rather - /// than answering about either, and MUST answer `false` — not revert — for - /// a nonzero writer that has simply not recorded a nonzero migration. + /// The implementation MUST revert `ZeroWriter`, `ZeroMigration` or + /// `GenesisMigration` rather than answering about any of them, and MUST + /// answer zero — not revert — for a real writer that has simply not recorded + /// a real migration. /// - /// That `false` is the deliberate difference from a registry whose reads - /// revert on an unknown key. "This migration has not been applied here" is - /// a legitimate, expected answer that a caller branches on and asserts the + /// That zero is the deliberate difference from a registry whose reads revert + /// on an unknown key. "This migration has not been applied here" is a + /// legitimate, expected answer that a caller branches on and asserts the /// pre-migration state for; it is the ordinary state of every migration /// before it runs, and of every migration on a chain that never got it. A /// revert there would leave a caller with nothing to say about the state it - /// is actually looking at, which is the whole failure this registry - /// removes. + /// is actually looking at, which is the whole failure this registry removes. + /// + /// Zero is unambiguous because `record` refuses to write a zero timestamp, + /// so no recorded migration can present as an unrecorded one. + /// @param writer The namespace to read. Never the zero address. + /// @param migration The migration to ask about. Never zero, never + /// `MIGRATION_HEAD_GENESIS`. + /// @return The block timestamp `writer` recorded `migration` at, or zero if + /// it has not. + function applied(address writer, bytes32 migration) external view returns (uint256); + + /// Where `writer`'s namespace currently is: the migration it recorded most + /// recently, or `MIGRATION_HEAD_GENESIS` if it has never recorded one. + /// + /// The implementation MUST revert `ZeroWriter` rather than answering about + /// the zero namespace, and MUST NEVER answer zero — an empty namespace is + /// genesis, and a nonempty one is a nonzero migration id, so a zero answer + /// could only mean the reader had reached something that is not this + /// registry. + /// + /// This is a read for authoring and for diagnosis: which migration a chain + /// is at, and therefore what the next script must name. It is NOT how a + /// script decides that its predecessor ran — that is `applied`, per + /// migration, because a head says only what was last, not what was ever. /// @param writer The namespace to read. Never the zero address. - /// @param migration The migration to ask about. Never zero. - /// @return Whether `writer` has recorded `migration`. - function applied(address writer, bytes32 migration) external view returns (bool); + /// @return The head of `writer`'s namespace. Never zero. + function head(address writer) external view returns (bytes32); } diff --git a/src/lib/LibMigrationRegistry.sol b/src/lib/LibMigrationRegistry.sol index 2851ce0..cfb88e3 100644 --- a/src/lib/LibMigrationRegistry.sol +++ b/src/lib/LibMigrationRegistry.sol @@ -13,10 +13,11 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// 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 answers whether a writer has recorded a -/// migration, and it records one under the caller. Which writer a test trusts, -/// which invariant each answer selects, and how an id is derived are entirely -/// the consumer's business and none of this library's. +/// That is the whole library. It answers when a writer recorded a migration and +/// where that writer's namespace has got to, and it records one under the +/// caller. Which writer a test trusts, which invariant each answer selects, and +/// how an id is derived are entirely the consumer's business and none of this +/// library's. /// /// ## There is deliberately no broadcast runner here /// @@ -37,7 +38,7 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// A test asserts EXACTLY the value implied by the migrations that have run: /// /// ```solidity -/// if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2)) { +/// if (LibMigrationRegistry.applied(SAFE, MIGRATION_V2) != 0) { /// assertEq(vault.owner(), NEW_OWNER); /// } else { /// assertEq(vault.owner(), OLD_OWNER); @@ -46,11 +47,30 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// /// Both branches assert. Neither reads the clock, neither skips, and the branch /// is selected by what happened on chain rather than by a deadline somebody -/// guessed. `applied` answering `false` is an ordinary, expected answer — it is +/// guessed. `applied` answering zero is an ordinary, expected answer — it is /// the state of every migration before it runs and of every migration on a /// chain that never got it — which is why the registry answers it rather than /// reverting. /// +/// The nonzero answer is WHEN, which is what a test whose invariant is itself +/// time-shaped needs: a cliff that starts at the migration, a rate that changes +/// a week after it. That is still the clock being read, but it is the chain's +/// record of the migration being read, not a date somebody guessed in advance. +/// +/// ## Writing names the head it is applying onto +/// +/// `record` takes the migration the caller believes ran last in its namespace, +/// so a chain that never got that predecessor refuses the write instead of +/// silently skipping a step, and two migrations dispatched at once cannot land +/// in the wrong order. The first migration in a namespace names +/// `MIGRATION_HEAD_GENESIS`, imported from the interface — never a zero, which +/// is what an uninitialised constant would be and is refused everywhere. +/// +/// `head` reads that value back, which is how an author finds what a new script +/// must name and how an operator sees which migration a chain is at. It is not +/// how a script tests that its predecessor ran: a head says what was LAST, and +/// `applied` is what says whether a particular migration ever ran at all. +/// /// The registry is an INDEX, not proof. It says which invariant applies; it does /// not say the invariant holds. A multisig can act out of band and nothing here /// moves. Codehash and bytecode pins are what verify the state itself, and this @@ -79,7 +99,7 @@ library LibMigrationRegistry { } } - /// Whether `writer` has recorded `migration`. + /// When `writer` recorded `migration`, or zero if it never did. /// /// 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 @@ -89,26 +109,46 @@ library LibMigrationRegistry { /// first into the second would send a caller down its pre-migration branch /// on every chain the registry was never deployed to. /// - /// The registry itself refuses the zero writer and the zero migration, so - /// those arrive as reverts from it rather than as `false`. + /// The registry itself refuses the zero writer, and refuses the two ids a + /// migration can never be, so those arrive as reverts from it rather than + /// as zero. /// @param writer The namespace to read — the authority whose record the /// caller trusts. Never the zero address. - /// @param migration The migration to ask about. Never zero. - /// @return Whether `writer` has recorded `migration`. - function applied(address writer, bytes32 migration) internal view returns (bool) { + /// @param migration The migration to ask about. Never zero, never + /// `MIGRATION_HEAD_GENESIS`. + /// @return The block timestamp `writer` recorded `migration` at, or zero if + /// it has not. + function applied(address writer, bytes32 migration) internal view returns (uint256) { checkCodeHash(); return IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS) .applied(writer, migration); } - /// Records `migration` under the CALLER's namespace. + /// The migration `writer` recorded most recently, or `MIGRATION_HEAD_GENESIS` + /// if it has never recorded one. + /// + /// Verifies the registry's code hash first for the same reason `applied` + /// does, and more sharply: a call into an empty account returns nothing, + /// which decodes as zero, and zero is the one value a head can never hold — + /// so an unverified read would hand back a head that is not a head at all, + /// on exactly the chains where nothing has been deployed. + /// @param writer The namespace to read. Never the zero address. + /// @return The head of `writer`'s namespace. Never zero. + function head(address writer) internal view returns (bytes32) { + checkCodeHash(); + return IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS).head(writer); + } + + /// Records `migration` under the CALLER's namespace, onto `expectedHead`. /// /// The caller is whoever the resulting transaction is sent from — a Safe /// executing a bundle, a broadcasting EOA, a timelock — and that account is /// the namespace the record lands in. A reader has to ask about that same /// account, so which account a migration is recorded from is a decision - /// with a consequence rather than an implementation detail. + /// with a consequence rather than an implementation detail. It is also the + /// account whose head this moves, so two unrelated sequences recorded from + /// one account interleave into one chain. /// /// Verifies the registry's code hash before writing, so a migration is /// never "recorded" into an empty address or into unknown code. A record @@ -116,12 +156,17 @@ library LibMigrationRegistry { /// have run, and every reader would go on asserting the pre-migration /// state. /// - /// The registry refuses the zero id and refuses a migration this caller has - /// already recorded, which is what makes a re-dispatched migration fail - /// rather than repeat. - /// @param migration The migration to record. Never zero. - function record(bytes32 migration) internal { + /// The registry refuses the zero id, refuses a migration this caller has + /// already recorded, and refuses one applied onto anything but the + /// namespace's actual head — which between them make a re-dispatched, a + /// skipped and an out-of-order migration all fail rather than land. + /// @param expectedHead The migration the caller believes it recorded last, + /// or `MIGRATION_HEAD_GENESIS` for the first in this namespace. + /// @param migration The migration to record. Never zero, never + /// `MIGRATION_HEAD_GENESIS`. + function record(bytes32 expectedHead, bytes32 migration) internal { checkCodeHash(); - IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS).record(migration); + IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS) + .record(expectedHead, migration); } } diff --git a/test/concrete/MockMigrationRecorder.sol b/test/concrete/MockMigrationRecorder.sol index 3f9da38..4d2d0b5 100644 --- a/test/concrete/MockMigrationRecorder.sol +++ b/test/concrete/MockMigrationRecorder.sol @@ -16,18 +16,30 @@ import {LibMigrationRegistry} from "../../src/lib/LibMigrationRegistry.sol"; /// therefore only ever write one namespace. Two of these are two namespaces, /// which is what makes "a record reaches nobody else" checkable rather than /// asserted about a single account. +/// +/// Two of them are also two heads, which is what makes "one namespace is one +/// sequence" checkable at all: nothing about one recorder's head can be shown +/// to leave the other's alone from inside a single namespace. contract MockMigrationRecorder { - /// Records `migration` under this contract. + /// Records `migration` under this contract, onto `expectedHead`. + /// @param expectedHead The head this contract believes it is at. /// @param migration The migration to record. - function record(bytes32 migration) external { - LibMigrationRegistry.record(migration); + function record(bytes32 expectedHead, bytes32 migration) external { + LibMigrationRegistry.record(expectedHead, migration); } - /// Whether `writer` has recorded `migration`. + /// When `writer` recorded `migration`. /// @param writer The namespace to read. /// @param migration The migration to ask about. - /// @return Whether it is recorded. - function applied(address writer, bytes32 migration) external view returns (bool) { + /// @return The timestamp it was recorded at, or zero. + function applied(address writer, bytes32 migration) external view returns (uint256) { return LibMigrationRegistry.applied(writer, migration); } + + /// The head of `writer`'s namespace. + /// @param writer The namespace to read. + /// @return The head. + function head(address writer) external view returns (bytes32) { + return LibMigrationRegistry.head(writer); + } } diff --git a/test/src/concrete/MigrationRegistryApplied.t.sol b/test/src/concrete/MigrationRegistryApplied.t.sol index a23837e..3246a16 100644 --- a/test/src/concrete/MigrationRegistryApplied.t.sol +++ b/test/src/concrete/MigrationRegistryApplied.t.sol @@ -4,13 +4,14 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; /// @title MigrationRegistryAppliedTest /// @notice A test suite for `MigrationRegistry.applied`: it answers a recorded -/// migration `true`, an unrecorded one `false`, refuses the two inputs that can -/// only be mistakes, and is the only reader. +/// migration with the moment it was recorded, an unrecorded one with zero, +/// refuses the three inputs that can only be mistakes, and is the only reader of +/// the records. contract MigrationRegistryAppliedTest is Test { /// The registry under test. Stateful, so a fresh one per test. MigrationRegistry internal sRegistry; @@ -19,40 +20,67 @@ contract MigrationRegistryAppliedTest is Test { sRegistry = new MigrationRegistry(); } - /// An unrecorded migration answers `false` rather than reverting. This is + /// A migration id that is neither of the two values the head space reserves. + /// @param migration The fuzzed candidate. + function assumeMigration(bytes32 migration) internal pure { + vm.assume(migration != bytes32(0)); + vm.assume(migration != MIGRATION_HEAD_GENESIS); + } + + /// An unrecorded migration answers zero rather than reverting. This is /// the deliberate difference from a registry whose reads revert on an /// unknown key: "not applied here" is the ordinary state of every migration /// before it runs and of every migration on a chain that never got it, and /// it is the answer a caller branches on to assert the pre-migration state /// exactly. A revert would leave the caller with nothing to say about the /// state it is actually looking at. - function testAppliedUnrecordedIsFalse(address writer, bytes32 migration) external view { + function testAppliedUnrecordedIsZero(address writer, bytes32 migration) external view { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); + + assertEq(sRegistry.applied(writer, migration), 0); + } - assertFalse(sRegistry.applied(writer, migration)); + /// A recorded migration answers the timestamp of the block it was recorded + /// in, and keeps answering it as time moves on. The value is when the + /// migration was applied, not how long ago or how recently anything was + /// asked. + function testAppliedIsTheRecordingTimestamp(address writer, bytes32 migration, uint32 recordedAt, uint32 readAt) + external + { + vm.assume(writer != address(0)); + assumeMigration(migration); + vm.assume(recordedAt != 0); + vm.assume(readAt >= recordedAt); + + vm.warp(recordedAt); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + vm.warp(readAt); + assertEq(sRegistry.applied(writer, migration), recordedAt); } /// Reading does not consume or alter a record, so the same question asked /// twice answers the same way. function testAppliedIsIdempotent(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(sRegistry.applied(writer, migration)); - assertTrue(sRegistry.applied(writer, migration)); + assertEq(sRegistry.applied(writer, migration), block.timestamp); + assertEq(sRegistry.applied(writer, migration), block.timestamp); } /// The zero writer is refused rather than answered. No transaction /// originates from the zero address, so that namespace is provably empty - /// and `false` would be the answer forever — an unresolved writer constant + /// and zero would be the answer forever — an unresolved writer constant /// would read as "nothing has been applied" instead of as the mistake it /// is, and send its caller down the pre-migration branch on every chain. function testAppliedZeroWriterReverts(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); sRegistry.applied(address(0), migration); @@ -67,23 +95,37 @@ contract MigrationRegistryAppliedTest is Test { sRegistry.applied(writer, bytes32(0)); } + /// The genesis head is refused as a migration for the same reason again: + /// `record` will not write it either, so asking about it would answer zero + /// forever to a caller that has confused a head for a migration — and that + /// caller reads zero as its pre-migration branch. + function testAppliedGenesisMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + sRegistry.applied(writer, MIGRATION_HEAD_GENESIS); + } + /// The writer is checked before the migration, so a caller that has zeroed /// both is told about the namespace first and gets one stable answer rather /// than one that depends on which check happens to run. function testAppliedZeroWriterCheckedFirst() external { vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); sRegistry.applied(address(0), bytes32(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.applied(address(0), MIGRATION_HEAD_GENESIS); } - /// A refusal is not a state change: the zero cases revert on a registry + /// A refusal is not a state change: the refused cases revert on a registry /// that holds records exactly as they do on an empty one, and leave those /// records intact. - function testAppliedZeroRefusalLeavesRecordsIntact(address writer, bytes32 migration) external { + function testAppliedRefusalLeavesRecordsIntact(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); sRegistry.applied(address(0), migration); @@ -91,12 +133,16 @@ contract MigrationRegistryAppliedTest is Test { vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); sRegistry.applied(writer, bytes32(0)); - assertTrue(sRegistry.applied(writer, migration)); + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + sRegistry.applied(writer, MIGRATION_HEAD_GENESIS); + + assertEq(sRegistry.applied(writer, migration), block.timestamp); + assertEq(sRegistry.head(writer), migration); } - /// `applied` is the only reader. The records mapping is not `public`, so - /// the getter a `public` mapping would generate — which answers the zero - /// writer and the zero migration with `false`, the exact silent + /// `applied` is the only reader of the records. The records mapping is not + /// `public`, so the getter a `public` mapping would generate — which answers + /// the zero writer and both refused ids with zero, the exact silent /// wrong-branch these refusals exist to prevent — does not exist. function testAppliedNoGeneratedMappingGetter(address writer, bytes32 migration) external { (bool success,) = @@ -104,12 +150,21 @@ contract MigrationRegistryAppliedTest is Test { assertFalse(success); } + /// Nor for the heads, where a generated getter would be worse still: it + /// answers an empty namespace with zero, and zero is a value no head can + /// ever hold. + function testAppliedNoGeneratedHeadGetter(address writer) external { + (bool success,) = address(sRegistry).call(abi.encodeWithSignature("sHead(address)", writer)); + assertFalse(success); + } + /// There is no other entry point at all: no fallback, no receive, and - /// nothing beyond the two `IMigrationRegistryV1` functions, so an unknown + /// nothing beyond the three `IMigrationRegistryV1` functions, so an unknown /// selector reverts instead of being silently absorbed. function testAppliedNoOtherEntryPoint(bytes4 selector, bytes32 migration) external { vm.assume(selector != IMigrationRegistryV1.applied.selector); vm.assume(selector != IMigrationRegistryV1.record.selector); + vm.assume(selector != IMigrationRegistryV1.head.selector); (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, address(this), migration)); assertFalse(success); diff --git a/test/src/concrete/MigrationRegistryHead.t.sol b/test/src/concrete/MigrationRegistryHead.t.sol new file mode 100644 index 0000000..9cb3eb6 --- /dev/null +++ b/test/src/concrete/MigrationRegistryHead.t.sol @@ -0,0 +1,162 @@ +// 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 {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; + +/// @title MigrationRegistryHeadTest +/// @notice A test suite for `MigrationRegistry.head`: where a namespace is, what +/// an empty one answers, that the answer is never a value that is not a head, +/// and that it is the same answer `record` checks against. +contract MigrationRegistryHeadTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + MigrationRegistry internal sRegistry; + + function setUp() external { + sRegistry = new MigrationRegistry(); + } + + /// A migration id that is neither of the two values the head space reserves. + /// @param migration The fuzzed candidate. + function assumeMigration(bytes32 migration) internal pure { + vm.assume(migration != bytes32(0)); + vm.assume(migration != MIGRATION_HEAD_GENESIS); + } + + /// A namespace that has recorded nothing is at genesis, which is an ANSWER + /// rather than a revert for the same reason an unrecorded migration answers + /// zero: it is the ordinary state of every namespace before its first + /// migration, and of every namespace on a chain that never got one. + function testHeadEmptyNamespaceIsGenesis(address writer) external view { + vm.assume(writer != address(0)); + + assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); + } + + /// Genesis is deliberately not zero, so an uninitialised predecessor + /// constant can never be mistaken for "the start of the sequence" — which + /// is the mistake that would otherwise pass on every chain that has not been + /// migrated yet. + function testHeadGenesisIsNotZero() external pure { + assertTrue(MIGRATION_HEAD_GENESIS != bytes32(0)); + } + + /// The head is the migration recorded most recently, and it moves with each + /// one. + function testHeadFollowsTheRecords(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + assertEq(sRegistry.head(writer), migrationA); + + vm.prank(writer); + sRegistry.record(migrationA, migrationB); + assertEq(sRegistry.head(writer), migrationB); + } + + /// Every writer has its own head, and one namespace's records leave every + /// other namespace exactly where it was. + function testHeadIsPerWriter(address writer, address other, bytes32 migration) external { + vm.assume(writer != address(0)); + vm.assume(other != address(0)); + vm.assume(writer != other); + assumeMigration(migration); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertEq(sRegistry.head(writer), migration); + assertEq(sRegistry.head(other), MIGRATION_HEAD_GENESIS); + } + + /// The head `head` reports is exactly the head `record` demands: whatever + /// this answers is accepted, and it is the only value that is. The two go + /// through one translation of an empty namespace, so they cannot disagree + /// about where one is. + function testHeadIsWhatRecordAccepts(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + // Hoisted, because `vm.prank` applies to the next call and reading the + // head is a call. + bytes32 headBeforeA = sRegistry.head(writer); + vm.prank(writer); + sRegistry.record(headBeforeA, migrationA); + + bytes32 headBeforeB = sRegistry.head(writer); + vm.prank(writer); + sRegistry.record(headBeforeB, migrationB); + + assertEq(sRegistry.head(writer), migrationB); + assertEq(sRegistry.applied(writer, migrationA), block.timestamp); + assertEq(sRegistry.applied(writer, migrationB), block.timestamp); + } + + /// The zero namespace is refused rather than answered genesis. It is + /// provably empty forever, so "nothing has been applied here" is true of it + /// and false of whatever the caller meant to ask about — and a caller that + /// believed it would send a first migration at it. + function testHeadZeroWriterReverts() external { + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.head(address(0)); + } + + /// Refused on a registry holding records exactly as on an empty one, and + /// the refusal changes nothing. + function testHeadZeroWriterRevertsWithRecordsPresent(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + sRegistry.head(address(0)); + + assertEq(sRegistry.head(writer), migration); + } + + /// A head is never zero, in any namespace state, which is what lets a + /// consumer treat a zero answer as "this is not the registry" rather than as + /// a namespace. + function testHeadIsNeverZero(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + + assertTrue(sRegistry.head(writer) != bytes32(0)); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertTrue(sRegistry.head(writer) != bytes32(0)); + } + + /// A refused record leaves the head where it was. The head moves only for a + /// migration that was actually recorded, so it can never describe a step + /// that did not happen. + function testHeadUnmovedByRefusedRecord(address writer, bytes32 migration, bytes32 wrongHead) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + assumeMigration(wrongHead); + vm.assume(wrongHead != migration); + + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, wrongHead, MIGRATION_HEAD_GENESIS + ) + ); + vm.prank(writer); + sRegistry.record(wrongHead, migration); + + assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); + } +} diff --git a/test/src/concrete/MigrationRegistryRecord.t.sol b/test/src/concrete/MigrationRegistryRecord.t.sol index 6cbbbca..dbecf42 100644 --- a/test/src/concrete/MigrationRegistryRecord.t.sol +++ b/test/src/concrete/MigrationRegistryRecord.t.sol @@ -4,13 +4,13 @@ pragma solidity =0.8.25; import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; -import {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; /// @title MigrationRegistryRecordTest /// @notice A test suite for `MigrationRegistry.record`: who a record belongs -/// to, that a migration is recorded at most once, and what a record may never -/// become. +/// to, that a migration is recorded at most once and only onto the head its +/// caller named, what a record carries, and what it may never become. contract MigrationRegistryRecordTest is Test { /// The registry under test. Stateful, so a fresh one per test. MigrationRegistry internal sRegistry; @@ -19,17 +19,84 @@ contract MigrationRegistryRecordTest is Test { sRegistry = new MigrationRegistry(); } + /// A migration id that is neither of the two values the head space reserves, + /// which is what every test that is not about those values wants. + /// @param migration The fuzzed candidate. + function assumeMigration(bytes32 migration) internal pure { + vm.assume(migration != bytes32(0)); + vm.assume(migration != MIGRATION_HEAD_GENESIS); + } + /// Anyone may record, and the record lands under the caller. There is no /// authority to be refused by, which is the whole access-control design: /// the namespace IS the caller. function testRecordAnyCallerRecordsUnderItself(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertEq(sRegistry.applied(writer, migration), block.timestamp); + } + + /// A record IS the block timestamp it landed in, which is the whole + /// difference from a flag: a consumer whose invariant starts AT the + /// migration — a cliff, a rate change, a grace period — reads the moment + /// from the chain rather than from a constant somebody guessed. + function testRecordStoresTheBlockTimestamp(address writer, bytes32 migration, uint32 timestamp) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + vm.assume(timestamp != 0); + vm.warp(timestamp); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertEq(sRegistry.applied(writer, migration), timestamp); + } + + /// Two migrations recorded in different blocks carry different timestamps, + /// and the earlier one does not move when the later one lands. A record is + /// of the moment it happened, not of the last time anything happened. + function testRecordTimestampsAreIndependent(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.warp(1000); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + vm.warp(2000); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(migrationA, migrationB); + + assertEq(sRegistry.applied(writer, migrationA), 1000); + assertEq(sRegistry.applied(writer, migrationB), 2000); + } + + /// A record refuses to be written at all in a block whose timestamp is zero, + /// rather than write one that `applied` would read back as no record. The + /// head does not move and the migration stays recordable, which is the only + /// outcome that leaves the namespace describing something true. + function testRecordZeroTimestampReverts(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + vm.warp(0); - assertTrue(sRegistry.applied(writer, migration)); + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroTimestamp.selector)); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertEq(sRegistry.applied(writer, migration), 0); + assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); + + vm.warp(1); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + assertEq(sRegistry.applied(writer, migration), 1); } /// A record is confined to the caller's namespace. Recording under one @@ -40,55 +107,197 @@ contract MigrationRegistryRecordTest is Test { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(sRegistry.applied(writer, migration)); - assertFalse(sRegistry.applied(other, migration)); + assertEq(sRegistry.applied(writer, migration), block.timestamp); + assertEq(sRegistry.applied(other, migration), 0); } /// Two writers may record the same migration id independently, and each /// answers only for itself. Ids are opaque and namespaces are unrelated, so - /// a shared id is not a collision. + /// a shared id is not a collision — including for the head, which each + /// writer advances from its own genesis. function testRecordSameMigrationUnderTwoWriters(address writer, address other, bytes32 migration) external { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.prank(other); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(sRegistry.applied(writer, migration)); - assertTrue(sRegistry.applied(other, migration)); + assertEq(sRegistry.applied(writer, migration), block.timestamp); + assertEq(sRegistry.applied(other, migration), block.timestamp); } /// Migrations are independent within one namespace: recording one says /// nothing about any other. This is what a set buys over a high-water mark - /// — migrations applied out of order, or one applied and its predecessor - /// not, are representable exactly rather than papered over by a single - /// comparable value. + /// — a reader asks about the migration its assertion actually depends on + /// rather than about a number that stands in for all of them. function testRecordDistinctMigrations(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); - vm.assume(migrationA != bytes32(0)); - vm.assume(migrationB != bytes32(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + + assertEq(sRegistry.applied(writer, migrationA), block.timestamp); + assertEq(sRegistry.applied(writer, migrationB), 0); + + vm.prank(writer); + sRegistry.record(migrationA, migrationB); + + assertEq(sRegistry.applied(writer, migrationA), block.timestamp); + assertEq(sRegistry.applied(writer, migrationB), block.timestamp); + } + + /// A successful record makes its migration the namespace's new head, which + /// is what the next one has to name. + function testRecordAdvancesTheHead(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + assertEq(sRegistry.head(writer), migrationA); + + vm.prank(writer); + sRegistry.record(migrationA, migrationB); + assertEq(sRegistry.head(writer), migrationB); + } + + /// A record onto a head the namespace is not at is refused. This is what + /// blocks a SKIPPED step: a script names its predecessor, so a chain that + /// never got that predecessor fails at the moment of applying rather than + /// diverging silently from every chain that did. + function testRecordSkippedPredecessorReverts( + address writer, + bytes32 migrationA, + bytes32 migrationB, + bytes32 skipped + ) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + assumeMigration(skipped); vm.assume(migrationA != migrationB); + vm.assume(skipped != migrationA); + vm.assume(skipped != migrationB); vm.prank(writer); - sRegistry.record(migrationA); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, skipped, migrationA) + ); + vm.prank(writer); + sRegistry.record(skipped, migrationB); + + assertEq(sRegistry.applied(writer, migrationB), 0); + assertEq(sRegistry.head(writer), migrationA); + } - assertTrue(sRegistry.applied(writer, migrationA)); - assertFalse(sRegistry.applied(writer, migrationB)); + /// Genesis stops being an acceptable head the moment anything is recorded, + /// so a first-migration script re-run against a namespace that has moved on + /// fails rather than restarting the sequence. + function testRecordOntoGenesisAfterFirstReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(migrationB); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); - assertTrue(sRegistry.applied(writer, migrationA)); - assertTrue(sRegistry.applied(writer, migrationB)); + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, MIGRATION_HEAD_GENESIS, migrationA + ) + ); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationB); + } + + /// A head belongs to one namespace. One writer advancing its head leaves + /// every other writer's exactly where it was, so a second consumer's + /// migrations are not blocked or unblocked by the first's. + function testRecordHeadIsPerWriter(address writer, address other, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + vm.assume(other != address(0)); + vm.assume(writer != other); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + + assertEq(sRegistry.head(other), MIGRATION_HEAD_GENESIS); + + // The other namespace is still at genesis, so `migrationA` is not the + // head there and naming it is refused. + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, other, migrationA, MIGRATION_HEAD_GENESIS + ) + ); + vm.prank(other); + sRegistry.record(migrationA, migrationB); + + vm.prank(other); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationB); + assertEq(sRegistry.head(other), migrationB); + assertEq(sRegistry.head(writer), migrationA); + } + + /// A zero head never matches anything, including on a namespace that has + /// recorded nothing — which is the whole reason genesis is not zero. An + /// uninitialised predecessor constant is a revert in every namespace state, + /// rather than a successful first record on every chain that happens to be + /// empty. + function testRecordZeroHeadRevertsOnEmptyNamespace(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, bytes32(0), MIGRATION_HEAD_GENESIS + ) + ); + vm.prank(writer); + sRegistry.record(bytes32(0), migration); + + assertEq(sRegistry.applied(writer, migration), 0); + } + + /// And on a namespace that has recorded something. + function testRecordZeroHeadRevertsOnUsedNamespace(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, bytes32(0), migrationA + ) + ); + vm.prank(writer); + sRegistry.record(bytes32(0), migrationB); } /// Recording twice is refused. This is what makes running a migration twice @@ -96,18 +305,73 @@ contract MigrationRegistryRecordTest is Test { /// its way to looking like a first run. function testRecordTwiceReverts(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert( abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) ); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + assertEq(sRegistry.applied(writer, migration), block.timestamp); + } + + /// The head does NOT subsume the already-recorded refusal. Re-recording a + /// migration whose successor has since landed presents a head that matches + /// perfectly, and is still refused — otherwise the head would move BACKWARDS + /// and the original timestamp would be overwritten, which is a record + /// un-happening. + function testRecordAgainOnMatchingHeadReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.warp(1000); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + vm.prank(writer); + sRegistry.record(migrationA, migrationB); + + // The namespace really is at `migrationB`, so the head this names is + // correct and only the already-recorded refusal can stop it. + assertEq(sRegistry.head(writer), migrationB); + vm.warp(2000); + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migrationA) + ); + vm.prank(writer); + sRegistry.record(migrationB, migrationA); - assertTrue(sRegistry.applied(writer, migration)); + assertEq(sRegistry.head(writer), migrationB); + assertEq(sRegistry.applied(writer, migrationA), 1000); + } + + /// The already-recorded refusal is checked BEFORE the head, so a + /// re-dispatched script — which names the same head it named the first time, + /// long since moved on — is told that its migration already ran rather than + /// told the namespace is somewhere else and left to work out why. + function testRecordAlreadyRecordedCheckedBeforeHead(address writer, bytes32 migrationA, bytes32 migrationB) + external + { + vm.assume(writer != address(0)); + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + vm.prank(writer); + sRegistry.record(migrationA, migrationB); + + vm.expectRevert( + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migrationA) + ); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); } /// A migration another writer has already recorded is still a FIRST record @@ -117,15 +381,15 @@ contract MigrationRegistryRecordTest is Test { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.prank(other); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(sRegistry.applied(other, migration)); + assertEq(sRegistry.applied(other, migration), block.timestamp); } /// The zero migration id is refused. It is what an uninitialised `bytes32` @@ -137,22 +401,53 @@ contract MigrationRegistryRecordTest is Test { vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(bytes32(0)); + sRegistry.record(MIGRATION_HEAD_GENESIS, bytes32(0)); } - /// The zero id is refused BEFORE the already-recorded read, so it is always - /// reported as `ZeroMigration` and never as a first record that later - /// collides. - function testRecordZeroMigrationCheckedFirst(address writer) external { + /// The zero id is refused BEFORE the already-recorded read and before the + /// head, so it is always reported as `ZeroMigration` and never as anything + /// about where the namespace is. + function testRecordZeroMigrationCheckedFirst(address writer, bytes32 anyHead) external { vm.assume(writer != address(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(bytes32(0)); + sRegistry.record(anyHead, bytes32(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(bytes32(0)); + sRegistry.record(anyHead, bytes32(0)); + } + + /// Genesis is a head, not a migration, and recording it is refused. It would + /// otherwise leave the namespace's head holding the exact value an empty + /// namespace reads as, so a namespace that had recorded something would be + /// indistinguishable from one that had not — and the next first-migration + /// script would be accepted against it. + function testRecordGenesisMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); + + assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); + } + + /// Refused whatever head it is applied onto, so it is a fact about the id + /// rather than about where the namespace happens to be. + function testRecordGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration) external { + vm.assume(writer != address(0)); + assumeMigration(migration); + + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + vm.prank(writer); + sRegistry.record(migration, MIGRATION_HEAD_GENESIS); + + assertEq(sRegistry.head(writer), migration); } /// Ids are opaque: nothing about a migration's bytes changes how it is @@ -164,21 +459,26 @@ contract MigrationRegistryRecordTest is Test { for (uint256 i = 0; i < migrations.length; i++) { MigrationRegistry registry = new MigrationRegistry(); vm.prank(writer); - registry.record(migrations[i]); - assertTrue(registry.applied(writer, migrations[i])); + registry.record(MIGRATION_HEAD_GENESIS, migrations[i]); + assertEq(registry.applied(writer, migrations[i]), block.timestamp); + assertEq(registry.head(writer), migrations[i]); } } /// `Migrated` is emitted with the writer and migration both indexed, so the /// log can be filtered by either. The log is the only enumeration of the /// registry, so a record that does not emit is a record nobody can find. + /// + /// It carries no head and no timestamp because the log already holds both: + /// one writer's entries in order ARE its chain of heads, and the timestamp + /// is the block's. function testRecordEvent(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.recordLogs(); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 1); @@ -195,23 +495,39 @@ contract MigrationRegistryRecordTest is Test { /// re-dispatched migration is exactly the mistake that matters. function testRecordNoEventOnRevert(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.recordLogs(); vm.expectRevert( abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) ); vm.prank(writer); - sRegistry.record(migration); + sRegistry.record(MIGRATION_HEAD_GENESIS, migration); assertEq(vm.getRecordedLogs().length, 0); vm.recordLogs(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(bytes32(0)); + sRegistry.record(migration, bytes32(0)); + assertEq(vm.getRecordedLogs().length, 0); + + vm.recordLogs(); + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + vm.prank(writer); + sRegistry.record(migration, MIGRATION_HEAD_GENESIS); + assertEq(vm.getRecordedLogs().length, 0); + + vm.recordLogs(); + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, MIGRATION_HEAD_GENESIS, migration + ) + ); + vm.prank(writer); + sRegistry.record(MIGRATION_HEAD_GENESIS, keccak256(abi.encode(migration))); assertEq(vm.getRecordedLogs().length, 0); } } diff --git a/test/src/lib/LibMigrationRegistry.t.sol b/test/src/lib/LibMigrationRegistry.t.sol index e20b453..fd8f971 100644 --- a/test/src/lib/LibMigrationRegistry.t.sol +++ b/test/src/lib/LibMigrationRegistry.t.sol @@ -6,7 +6,7 @@ import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibMigrationRegistry} from "../../../src/lib/LibMigrationRegistry.sol"; import {LibMigrationRegistryDeploy} from "../../../src/lib/LibMigrationRegistryDeploy.sol"; import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; -import {IMigrationRegistryV1} from "../../../src/interface/IMigrationRegistryV1.sol"; +import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; import {MockMigrationRecorder} from "../../concrete/MockMigrationRecorder.sol"; @@ -27,20 +27,36 @@ contract LibMigrationRegistryTest is Test { return IMigrationRegistryV1(LibRainDeploy.deployZoltu(type(MigrationRegistry).creationCode)); } + /// A migration id that is neither of the two values the head space reserves. + /// @param migration The fuzzed candidate. + function assumeMigration(bytes32 migration) internal pure { + vm.assume(migration != bytes32(0)); + vm.assume(migration != MIGRATION_HEAD_GENESIS); + } + /// External wrapper for `applied` so that `vm.expectRevert` works at the /// correct call depth. /// @param writer The namespace to read. /// @param migration The migration to ask about. - /// @return Whether `writer` has recorded `migration`. - function externalApplied(address writer, bytes32 migration) external view returns (bool) { + /// @return When `writer` recorded `migration`, or zero. + function externalApplied(address writer, bytes32 migration) external view returns (uint256) { return LibMigrationRegistry.applied(writer, migration); } + /// External wrapper for `head` so that `vm.expectRevert` works at the + /// correct call depth. + /// @param writer The namespace to read. + /// @return The head of that namespace. + function externalHead(address writer) external view returns (bytes32) { + return LibMigrationRegistry.head(writer); + } + /// External wrapper for `record` so that `vm.expectRevert` works at the /// correct call depth. + /// @param expectedHead The head this contract believes it is at. /// @param migration The migration to record. - function externalRecord(bytes32 migration) external { - LibMigrationRegistry.record(migration); + function externalRecord(bytes32 expectedHead, bytes32 migration) external { + LibMigrationRegistry.record(expectedHead, migration); } /// The Zoltu deploy really does land the registry on its pinned address @@ -56,27 +72,65 @@ contract LibMigrationRegistryTest is Test { ); } - /// An unrecorded migration answers `false`. This is the branch a caller + /// An unrecorded migration answers zero. This is the branch a caller /// asserts the pre-migration state in, and it is the ordinary state of /// every migration that has not run, so it is an answer rather than a /// revert. - function testAppliedUnrecordedIsFalse(address writer, bytes32 migration) external { + function testAppliedUnrecordedIsZero(address writer, bytes32 migration) external { vm.assume(writer != address(0)); - vm.assume(migration != bytes32(0)); + assumeMigration(migration); deployRegistry(); - assertFalse(LibMigrationRegistry.applied(writer, migration)); + assertEq(LibMigrationRegistry.applied(writer, migration), 0); } - /// A recorded migration answers `true` — read back through the library, so - /// what `record` writes is what `applied` finds. - function testRecordThenApplied(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + /// A recorded migration answers the moment it was recorded — read back + /// through the library, so what `record` writes is what `applied` finds. + function testRecordThenApplied(bytes32 migration, uint32 recordedAt) external { + assumeMigration(migration); + vm.assume(recordedAt != 0); deployRegistry(); + vm.warp(recordedAt); - LibMigrationRegistry.record(migration); + LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(LibMigrationRegistry.applied(address(this), migration)); + assertEq(LibMigrationRegistry.applied(address(this), migration), recordedAt); + } + + /// A namespace that has recorded nothing reads back as genesis, and each + /// record moves the head to itself. This is the value the next migration + /// has to name, so it is read through the library rather than assumed. + function testHeadFollowsTheRecords(bytes32 migrationA, bytes32 migrationB) external { + assumeMigration(migrationA); + assumeMigration(migrationB); + vm.assume(migrationA != migrationB); + deployRegistry(); + + assertEq(LibMigrationRegistry.head(address(this)), MIGRATION_HEAD_GENESIS); + + LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + assertEq(LibMigrationRegistry.head(address(this)), migrationA); + + LibMigrationRegistry.record(migrationA, migrationB); + assertEq(LibMigrationRegistry.head(address(this)), migrationB); + } + + /// A migration applied onto a head this namespace is not at is refused, and + /// the registry's own revert arrives unmodified. This is a skipped step + /// failing at the moment of applying rather than a chain quietly diverging. + function testRecordSkippedPredecessorReverts(bytes32 migration, bytes32 skipped) external { + assumeMigration(migration); + assumeMigration(skipped); + deployRegistry(); + + vm.expectRevert( + abi.encodeWithSelector( + IMigrationRegistryV1.UnexpectedMigrationHead.selector, address(this), skipped, MIGRATION_HEAD_GENESIS + ) + ); + this.externalRecord(skipped, migration); + + assertEq(LibMigrationRegistry.applied(address(this), migration), 0); } /// The namespace is the CONTRACT that executes the library call. The @@ -85,44 +139,47 @@ contract LibMigrationRegistryTest is Test { /// chooses its namespace by choosing what sends the transaction, and cannot /// write anybody else's. function testRecordLandsUnderTheCallingContract(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + assumeMigration(migration); deployRegistry(); MockMigrationRecorder recorder = new MockMigrationRecorder(); - recorder.record(migration); + recorder.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(LibMigrationRegistry.applied(address(recorder), migration)); - assertFalse(LibMigrationRegistry.applied(address(this), migration)); + assertEq(LibMigrationRegistry.applied(address(recorder), migration), block.timestamp); + assertEq(LibMigrationRegistry.applied(address(this), migration), 0); } /// One caller's record reaches no other namespace, and each answers only - /// for itself. This is the whole of the access control: a reader's choice - /// of writer is the whole of who it trusts. + /// for itself — heads included, so one consumer's sequence neither blocks + /// nor unblocks another's. This is the whole of the access control: a + /// reader's choice of writer is the whole of who it trusts. function testRecordDoesNotReachAnotherNamespace(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + assumeMigration(migration); deployRegistry(); MockMigrationRecorder recorder = new MockMigrationRecorder(); MockMigrationRecorder other = new MockMigrationRecorder(); - recorder.record(migration); + recorder.record(MIGRATION_HEAD_GENESIS, migration); - assertTrue(other.applied(address(recorder), migration)); - assertFalse(other.applied(address(other), migration)); + assertEq(other.applied(address(recorder), migration), block.timestamp); + assertEq(other.applied(address(other), migration), 0); + assertEq(other.head(address(recorder)), migration); + assertEq(other.head(address(other)), MIGRATION_HEAD_GENESIS); } /// Recording the same migration twice is refused, and the registry's own /// revert arrives unmodified — the library adds no handling of its own, so /// a re-dispatched migration fails naming the writer and the id. function testRecordTwiceReverts(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + assumeMigration(migration); deployRegistry(); - LibMigrationRegistry.record(migration); + LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert( abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, address(this), migration) ); - this.externalRecord(migration); + this.externalRecord(MIGRATION_HEAD_GENESIS, migration); } /// The registry's zero-id refusal arrives unmodified through `record`. @@ -130,12 +187,20 @@ contract LibMigrationRegistryTest is Test { deployRegistry(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); - this.externalRecord(bytes32(0)); + this.externalRecord(MIGRATION_HEAD_GENESIS, bytes32(0)); + } + + /// The registry's genesis-id refusal arrives unmodified through `record`. + function testRecordGenesisMigrationReverts() external { + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + this.externalRecord(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); } /// The registry's zero-writer refusal arrives unmodified through `applied`. function testAppliedZeroWriterReverts(bytes32 migration) external { - vm.assume(migration != bytes32(0)); + assumeMigration(migration); deployRegistry(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); @@ -151,9 +216,26 @@ contract LibMigrationRegistryTest is Test { this.externalApplied(writer, bytes32(0)); } + /// The registry's genesis-id refusal arrives unmodified through `applied`. + function testAppliedGenesisMigrationReverts(address writer) external { + vm.assume(writer != address(0)); + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + this.externalApplied(writer, MIGRATION_HEAD_GENESIS); + } + + /// The registry's zero-writer refusal arrives unmodified through `head`. + function testHeadZeroWriterReverts() external { + deployRegistry(); + + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); + this.externalHead(address(0)); + } + /// A chain with no registry deployed reverts on the code hash rather than /// calling into an empty account. That call would succeed and return - /// nothing, which `abi.decode` would read as `false` — "this migration has + /// nothing, which `abi.decode` would read as zero — "this migration has /// not been applied", on every chain the registry was never deployed to, /// which is exactly the silent pre-migration branch this library exists to /// make impossible. @@ -170,11 +252,28 @@ contract LibMigrationRegistryTest is Test { this.externalApplied(writer, migration); } + /// Reading a head off a chain with no registry is refused for a sharper + /// version of the same reason: the empty-account read decodes as zero, and + /// zero is a value no head can ever hold, so an unverified read hands back + /// something that is not a head at all. + function testHeadNoRegistry(address writer) external { + assertEq(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + bytes32(0) + ) + ); + this.externalHead(writer); + } + /// Writing to a chain with no registry is refused for the mirror reason: a /// `record` into an empty account is a migration that reports itself /// recorded and is not, which leaves every reader asserting the /// pre-migration state forever. - function testRecordNoRegistry(bytes32 migration) external { + function testRecordNoRegistry(bytes32 expectedHead, bytes32 migration) external { assertEq(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); vm.expectRevert( @@ -184,7 +283,7 @@ contract LibMigrationRegistryTest is Test { bytes32(0) ) ); - this.externalRecord(migration); + this.externalRecord(expectedHead, migration); } /// A chain where something other than the pinned registry occupies the @@ -205,8 +304,24 @@ contract LibMigrationRegistryTest is Test { this.externalApplied(writer, migration); } + /// Nor is a head. + function testHeadWrongCode(address writer, bytes memory code) external { + vm.assume(code.length > 0); + vm.assume(keccak256(code) != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH); + vm.etch(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, code); + + vm.expectRevert( + abi.encodeWithSelector( + LibMigrationRegistry.UnexpectedMigrationRegistryCodeHash.selector, + LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH, + keccak256(code) + ) + ); + this.externalHead(writer); + } + /// And never recorded into it either. - function testRecordWrongCode(bytes32 migration, bytes memory code) external { + function testRecordWrongCode(bytes32 expectedHead, bytes32 migration, bytes memory code) external { vm.assume(code.length > 0); vm.assume(keccak256(code) != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH); vm.etch(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, code); @@ -218,6 +333,6 @@ contract LibMigrationRegistryTest is Test { keccak256(code) ) ); - this.externalRecord(migration); + this.externalRecord(expectedHead, migration); } } From 7807d08b088ecc2da60048451acd69e588570adb Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 06:52:35 +0000 Subject: [PATCH 08/11] fix(migration-registry): suppress the timestamp analysers without breaking fmt The `block.timestamp == 0` guard draws three static warnings: slither's `incorrect-equality` and `timestamp`, and forge-lint's `block-timestamp`. Only one comment fits in the line immediately above the `if`, and a trailing `forge-lint: disable-line` is moved inside the braces by `forge fmt`, so `forge fmt --check` fails on it. Slither's pair becomes a `slither-disable-start`/`-end` bracket around the statement, which frees the immediately-above line for forge-lint's `disable-next-line`. Comment-only: the pins are byte-identical. `slither .` reports 0 results, `forge fmt --check` is clean, and `forge lint` reports only the pre-existing `unsafe-typecast` in `LibRainDeploy`. Co-Authored-By: Claude Opus 5 (1M context) --- src/concrete/MigrationRegistry.sol | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/concrete/MigrationRegistry.sol b/src/concrete/MigrationRegistry.sol index 3f22b57..a044242 100644 --- a/src/concrete/MigrationRegistry.sol +++ b/src/concrete/MigrationRegistry.sol @@ -120,12 +120,18 @@ contract MigrationRegistry is IMigrationRegistryV1 { // across a threshold. There is no threshold here and no nudge // available: zero is not a value a validator on a live chain can // produce at all, which is why this is an equality against it rather - // than a window around it, and why the two warnings are suppressed on - // this line alone rather than turned off for the repo. - // slither-disable-next-line incorrect-equality,timestamp - if (block.timestamp == 0) { // forge-lint: disable-line(block-timestamp) + // than a window around it, and why all three warnings are suppressed on + // this one comparison rather than turned off for the repo. + // + // Slither's two are a start/end pair rather than a next-line because + // only one comment fits immediately above the `if`, `forge fmt` moves a + // trailing one inside the braces, and forge-lint has no pair form. + // slither-disable-start incorrect-equality,timestamp + // forge-lint: disable-next-line(block-timestamp) + if (block.timestamp == 0) { revert ZeroTimestamp(); } + // slither-disable-end incorrect-equality,timestamp sApplied[msg.sender][migration] = block.timestamp; sHead[msg.sender] = migration; emit Migrated(msg.sender, migration); From f8c92670d767aa9860dea9be277016fd3ca494a0 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 07:03:28 +0000 Subject: [PATCH 09/11] test(migration-registry): fuzz the head genesis is refused onto `testRecordGenesisMigrationRevertsOnAnyHead` claimed genesis is refused as a migration "whatever head it is applied onto", but only ever passed a head the namespace was actually at. A mutation moving the genesis refusal after the head check therefore survived: on a mismatching head it answers `UnexpectedMigrationHead` instead of `GenesisMigration`, and nothing noticed. The head is now fuzzed, against an empty namespace and a moved one, exactly as the zero-id sibling `testRecordZeroMigrationCheckedFirst` already did. The mutant is killed by this test. Co-Authored-By: Claude Opus 5 (1M context) --- .../concrete/MigrationRegistryRecord.t.sol | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/src/concrete/MigrationRegistryRecord.t.sol b/test/src/concrete/MigrationRegistryRecord.t.sol index dbecf42..f5b7c17 100644 --- a/test/src/concrete/MigrationRegistryRecord.t.sol +++ b/test/src/concrete/MigrationRegistryRecord.t.sol @@ -435,18 +435,37 @@ contract MigrationRegistryRecordTest is Test { } /// Refused whatever head it is applied onto, so it is a fact about the id - /// rather than about where the namespace happens to be. - function testRecordGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration) external { + /// rather than about where the namespace happens to be. That means a head + /// the namespace is NOT at as much as one it is: the refusal is checked + /// before the head, so a caller that has confused a head for a migration is + /// told which of the two it got wrong rather than sent to look at where the + /// namespace has got to. + /// + /// Fuzzed over the head for the same reason `testRecordZeroMigrationCheckedFirst` + /// is: a matching head alone cannot tell the two orderings apart. + function testRecordGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration, bytes32 anyHead) external { vm.assume(writer != address(0)); assumeMigration(migration); + // An empty namespace, whose head is genesis: still refused onto a head + // that does not match it. + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + vm.prank(writer); + sRegistry.record(anyHead, MIGRATION_HEAD_GENESIS); + vm.prank(writer); sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + // A namespace that has moved: same refusal, onto the head it is at and + // onto any other. vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); sRegistry.record(migration, MIGRATION_HEAD_GENESIS); + vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); + vm.prank(writer); + sRegistry.record(anyHead, MIGRATION_HEAD_GENESIS); + assertEq(sRegistry.head(writer), migration); } From 5d8c40a11b74bdbb1afce11006aed823d5456c79 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 07:46:53 +0000 Subject: [PATCH 10/11] refactor(migration-registry): applyMigration names the operation, head absorbs readHead Rename every verb form of `record` naming the operation to `applyMigration`, including `MigrationAlreadyRecorded` -> `MigrationAlreadyApplied`. The noun sense of "record" (the stored fact) is unchanged, and `unrecord` is kept. Fold the internal `readHead` into a single `public head` that holds both the `ZeroWriter` refusal and the zero -> `MIGRATION_HEAD_GENESIS` translation. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 40 ++-- README.md | 39 ++-- src/concrete/MigrationRegistry.sol | 69 +++--- src/generated/candidate/MigrationRegistry.sol | 8 +- src/interface/IMigrationRegistryV1.sol | 133 +++++------ src/lib/LibMigrationRegistry.sol | 45 ++-- ...nRecorder.sol => MockMigrationApplier.sol} | 24 +- .../concrete/MigrationRegistryApplied.t.sol | 36 +-- ... => MigrationRegistryApplyMigration.t.sol} | 219 +++++++++--------- test/src/concrete/MigrationRegistryHead.t.sol | 40 ++-- test/src/lib/LibMigrationRegistry.t.sol | 103 ++++---- 11 files changed, 384 insertions(+), 372 deletions(-) rename test/concrete/{MockMigrationRecorder.sol => MockMigrationApplier.sol} (67%) rename test/src/concrete/{MigrationRegistryRecord.t.sol => MigrationRegistryApplyMigration.t.sol} (66%) diff --git a/CLAUDE.md b/CLAUDE.md index 857b309..e04e3be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,9 +128,9 @@ against it by `RegistryDeploySnapshotTest`. GENERATED by `script/Build.sol`, aliasing the rolling `src/generated/candidate/` snapshot. **`src/interface/IMigrationRegistryV1.sol`** — the migration registry interface: -a writer records one of its own migrations onto the head it believes its -namespace is at (`record`), anyone reads when a given writer recorded a given -migration (`applied`), and anyone reads where that writer's namespace is +a writer applies one of its own migrations onto the head it believes its +namespace is at (`applyMigration`), anyone reads when a given writer applied a +given migration (`applied`), and anyone reads where that writer's namespace is (`head`). There is no removal, no upgrade and no authority beyond the writer over its own namespace. @@ -145,25 +145,25 @@ bytecode pins are what say the invariant holds. A multisig can act out of band, so replacing the pins with this would trade a clock-guess for a bookkeeping guess. -`applied` answers a TIMESTAMP, and zero still means "not recorded". Time-shaped +`applied` answers a TIMESTAMP, and zero still means "not applied". Time-shaped invariants — a cliff, a rate change, a grace period — need the moment as well as the fact, and a flag sends them back to the hardcoded date. Zero stays -unambiguous because `record` refuses to write in a block whose timestamp is -zero, rather than write a record that reads back as no record. - -The HEAD is what makes a sequence ordered. `record` names the migration it is -applying onto and refuses to write unless the namespace is there, so a skipped -predecessor and an out-of-order concurrent dispatch both fail at apply time -instead of diverging silently; the recorded migration becomes the new head. -`MIGRATION_HEAD_GENESIS` is the head of a namespace that has recorded nothing, +unambiguous because `applyMigration` refuses to write in a block whose timestamp +is zero, rather than write a record that reads back as no record. + +The HEAD is what makes a sequence ordered. `applyMigration` names the migration +it is applying onto and refuses to write unless the namespace is there, so a +skipped predecessor and an out-of-order concurrent dispatch both fail at apply +time instead of diverging silently; the applied migration becomes the new head. +`MIGRATION_HEAD_GENESIS` is the head of a namespace that has applied nothing, and it is deliberately NOT zero: a zero genesis would make an uninitialised -predecessor constant a successful first record on any empty namespace, which is -the state of every chain not yet migrated. It is not a valid migration id -either, for the same reason a head must mean one thing. +predecessor constant a successful first application on any empty namespace, +which is the state of every chain not yet migrated. It is not a valid migration +id either, for the same reason a head must mean one thing. -The head does not replace the per-migration refusal. Re-recording a migration +The head does not replace the per-migration refusal. Re-applying a migration whose successor has landed presents a matching head, and without -`MigrationAlreadyRecorded` would drag the head backwards and overwrite the +`MigrationAlreadyApplied` would drag the head backwards and overwrite the original timestamp. One namespace on one chain is one linear sequence, so two independent sequences want two writer accounts. @@ -187,11 +187,11 @@ does its whole job the moment it exists on a chain, which is the opposite of `AddressRegistry` under a zero root. **`src/lib/LibMigrationRegistry.sol`** — the consumer surface: `applied`, `head` -and `record`, all verifying the registry's code hash first, exactly as +and `applyMigration`, all verifying the registry's code hash first, exactly as `LibAddressRegistry.resolve` does. There is deliberately no broadcast runner: the dominant real migration shape is a Safe executing a bundle that never -broadcasts, and such a script appends `record` to the bundle it is already -emitting, which is what makes the record atomic with the migration. +broadcasts, and such a script appends `applyMigration` to the bundle it is +already emitting, which is what makes the record atomic with the migration. **`src/lib/LibMigrationRegistryDeploy.sol`** — its pins, generated exactly as `LibAddressRegistryDeploy` is. diff --git a/README.md b/README.md index 2c32ba7..88ccf44 100644 --- a/README.md +++ b/README.md @@ -185,10 +185,10 @@ library supplies the fork loop and the comparison. ## Migration registry `MigrationRegistry` records that a migration has been applied, and when: a -writer records one of its own onto the migration it believes ran last -(`record`), anyone reads when a given writer recorded a given one (`applied`), -and anyone reads where a given writer's sequence has got to (`head`). There is -no removal and no upgrade. +writer applies one of its own onto the migration it believes ran last +(`applyMigration`), anyone reads when a given writer applied a given one +(`applied`), and anyone reads where a given writer's sequence has got to +(`head`). There is no removal and no upgrade. It exists because prod-state tests otherwise decide what to assert by reading the **clock**. The pattern that emerges without it is a dual-state invariant — @@ -229,34 +229,35 @@ the ordering between migrations moves into the assertion — dependency actually lives. **A head, so a step cannot be skipped or repeated.** A namespace has a head: the -migration it recorded most recently, or `MIGRATION_HEAD_GENESIS` if it has -recorded none. `record` names the head it is applying onto, so a chain that -never got the predecessor fails at the moment of applying rather than diverging -silently, and two migrations dispatched at once cannot land in the wrong order. +migration it applied most recently, or `MIGRATION_HEAD_GENESIS` if it has +applied none. `applyMigration` names the head it is applying onto, so a chain +that never got the predecessor fails at the moment of applying rather than +diverging silently, and two migrations dispatched at once cannot land in the +wrong order. ```solidity // The first migration in a namespace. -LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, MIGRATION_V1); +LibMigrationRegistry.applyMigration(MIGRATION_HEAD_GENESIS, MIGRATION_V1); // Every later one names its predecessor. -LibMigrationRegistry.record(MIGRATION_V1, MIGRATION_V2); +LibMigrationRegistry.applyMigration(MIGRATION_V1, MIGRATION_V2); ``` Genesis is deliberately **not zero**. Zero is what an uninitialised `bytes32` constant reads as, and a zero genesis would make a mis-set predecessor constant -a _successful_ first record on any namespace that happens to be empty — the +a _successful_ first application on any namespace that happens to be empty — the state of every chain that has not been migrated yet, which is exactly where such a mistake is most likely. A nonzero genesis makes it a revert everywhere. The head does **not** replace the per-migration refusal, and both are kept. -Re-recording a migration whose successor has landed names a head that matches -perfectly; without `MigrationAlreadyRecorded` it would drag the head backwards +Re-applying a migration whose successor has landed names a head that matches +perfectly; without `MigrationAlreadyApplied` it would drag the head backwards and overwrite the original timestamp, which is a record un-happening. The two answer different questions — the head is _where in the sequence_, the record is _whether at all_. One namespace on one chain is therefore one linear sequence. Two unrelated sets of migrations applied from the same account interleave into one chain of heads, -so a consumer that wants two independent sequences records them from two +so a consumer that wants two independent sequences applies them from two accounts — the same lever that already decides who a reader trusts. **The namespace is `msg.sender`, and that is the whole access control.** Anyone @@ -275,11 +276,11 @@ say the invariant holds — a multisig can act out of band and nothing here move Keep both layers: this selects, codehash and bytecode pins verify. Replacing the pins with it trades a clock-guess for a bookkeeping-guess. -`LibMigrationRegistry` is the surface — `applied`, `head` and `record`, all -verifying the registry's code hash first. There is deliberately **no broadcast -runner**: the dominant real shape is a Safe executing a bundle that never -broadcasts, and such a script appends `record` to the bundle it is already -emitting, which makes the record atomic with the migration it describes. +`LibMigrationRegistry` is the surface — `applied`, `head` and `applyMigration`, +all verifying the registry's code hash first. There is deliberately **no +broadcast runner**: the dominant real shape is a Safe executing a bundle that +never broadcasts, and such a script appends `applyMigration` to the bundle it is +already emitting, which makes the record atomic with the migration it describes. ## Deploying, and then releasing diff --git a/src/concrete/MigrationRegistry.sol b/src/concrete/MigrationRegistry.sol index a044242..7effdee 100644 --- a/src/concrete/MigrationRegistry.sol +++ b/src/concrete/MigrationRegistry.sol @@ -5,9 +5,9 @@ pragma solidity =0.8.25; import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../interface/IMigrationRegistryV1.sol"; /// @title MigrationRegistry -/// @notice The whole of `IMigrationRegistryV1`: a writer records one of its own +/// @notice The whole of `IMigrationRegistryV1`: a writer applies one of its own /// migrations onto the head it believes its namespace is at, and anyone reads -/// when a given writer recorded a given migration, or where that writer's +/// when a given writer applied a given migration, or where that writer's /// namespace has got to. /// /// There is deliberately nothing else. No removal, no upgrade, no pause, and no @@ -35,17 +35,17 @@ import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../interface/IMigrat /// on a chain. /// /// A record is append-only per writer, and a head only ever moves forward onto -/// something new. `record` refuses a migration the caller has already recorded, -/// which is what makes re-running a migration fail rather than repeat, and -/// refuses one applied onto anything but the namespace's current head, which is -/// what makes a skipped or out-of-order migration fail rather than diverge. +/// something new. `applyMigration` refuses a migration the caller has already +/// applied, which is what makes re-running a migration fail rather than repeat, +/// and refuses one applied onto anything but the namespace's current head, which +/// is what makes a skipped or out-of-order migration fail rather than diverge. /// There is no way to unrecord one — a record describes something that happened, /// and nothing that happened stops having happened. /// /// Neither storage mapping is `public`. `applied` and `head` refuse the zero /// writer, `applied` refuses the two ids a migration can never be, and a public /// mapping's generated getter would answer all of them with zero — which for -/// `applied` is "not recorded" and for `head` is a value no head can ever hold, +/// `applied` is "not applied" and for `head` is a value no head can ever hold, /// i.e. exactly the silent wrong-branch this contract reverts to prevent. contract MigrationRegistry is IMigrationRegistryV1 { /// When each record landed, namespaced by writer. Zero means never. Not @@ -53,38 +53,27 @@ contract MigrationRegistry is IMigrationRegistryV1 { /// can only be mistakes. mapping(address writer => mapping(bytes32 migration => uint256 appliedAt)) internal sApplied; - /// The most recent migration recorded under each writer. Zero means the + /// The most recent migration applied under each writer. Zero means the /// namespace is empty, which reads out as `MIGRATION_HEAD_GENESIS` — the - /// only place that translation happens is `readHead`, so no reader and no + /// only place that translation happens is `head`, so no reader and no /// writer can disagree about where an empty namespace is. Not `public`, for /// the same reason as the records: the untranslated zero is not a head. mapping(address writer => bytes32 head) internal sHead; - /// The head of `writer`'s namespace, with an empty namespace translated to - /// genesis. One function, because `record` compares against it and `head` - /// returns it, and the two cannot be allowed to drift into different ideas - /// of where a namespace that has recorded nothing is. - /// @param writer The namespace to read. - /// @return The head. Never zero. - function readHead(address writer) internal view returns (bytes32) { - bytes32 recordedHead = sHead[writer]; - return recordedHead == bytes32(0) ? MIGRATION_HEAD_GENESIS : recordedHead; - } - /// @inheritdoc IMigrationRegistryV1 /// @dev The refusals run caller-input first and environment last: the two /// that describe a mistake in the call are true whatever block this lands /// in, so they are what a caller is told about first. - function record(bytes32 expectedHead, bytes32 migration) external { + function applyMigration(bytes32 expectedHead, bytes32 migration) external { // Checked before everything else, so an uninitialised id is reported as // the mistake it is rather than as a first record of zero. if (migration == bytes32(0)) { revert ZeroMigration(); } - // Genesis is a head, not a migration. Recording it would leave `sHead` + // Genesis is a head, not a migration. Applying it would leave `sHead` // holding the value an empty namespace reads as, so a namespace that had - // recorded something would be at a head indistinguishable from one that - // had recorded nothing — and the next first-migration script would be + // applied something would be at a head indistinguishable from one that + // had applied nothing — and the next first-migration script would be // accepted against it. if (migration == MIGRATION_HEAD_GENESIS) { revert GenesisMigration(); @@ -92,26 +81,26 @@ contract MigrationRegistry is IMigrationRegistryV1 { // There is deliberately no zero-writer case here. `msg.sender` cannot // be the zero address, so the zero namespace is unreachable for writes // and a guard on it would be unreachable code pretending to be a check. - // Nor is there a zero-head case: a head is either genesis or a recorded + // Nor is there a zero-head case: a head is either genesis or an applied // id, both nonzero, so a zero `expectedHead` can never match and is // already refused below, by an error that names the zero it was handed. // Checked before the head, because a migration that has already run has // already run whatever the head is, and that is the more useful thing to // say to a re-dispatched script. It is also not implied by the head - // check: re-recording a migration whose successor has landed presents a + // check: re-applying a migration whose successor has landed presents a // matching head, and would drag the head backwards and overwrite the // original timestamp. if (sApplied[msg.sender][migration] != 0) { - revert MigrationAlreadyRecorded(msg.sender, migration); + revert MigrationAlreadyApplied(msg.sender, migration); } - bytes32 actualHead = readHead(msg.sender); + bytes32 actualHead = head(msg.sender); if (expectedHead != actualHead) { revert UnexpectedMigrationHead(msg.sender, expectedHead, actualHead); } // A zero timestamp is the one value a record cannot carry: `applied` - // would answer it as "never recorded" while the head had moved and the - // migration could never be recorded again. Not unreachable — a test can + // would answer it as "never applied" while the head had moved and the + // migration could never be applied again. Not unreachable — a test can // warp to zero and a chain can be configured from a zero genesis — so // this is a real check rather than a decorative one. // @@ -140,8 +129,8 @@ contract MigrationRegistry is IMigrationRegistryV1 { /// @inheritdoc IMigrationRegistryV1 /// @dev All three refusals are about a caller that has not supplied what it /// thinks it has. None can ever be a real record: nothing originates from - /// the zero address, and `record` will write neither the zero id nor the - /// genesis one — so answering zero for any of them would be answering a + /// the zero address, and `applyMigration` will write neither the zero id nor + /// the genesis one — so answering zero for any of them would be answering a /// question the caller did not mean to ask, and answering it with the value /// that sends it down its pre-migration branch. function applied(address writer, bytes32 migration) external view returns (uint256) { @@ -162,10 +151,22 @@ contract MigrationRegistry is IMigrationRegistryV1 { /// provably empty forever, so "a namespace nothing has been applied to" is a /// true statement about it and a false one about what the caller meant to /// ask, which would send a first migration at it. - function head(address writer) external view returns (bytes32) { + /// + /// The empty-namespace zero is translated to genesis here and nowhere else, + /// which is why this is one `public` function rather than a reader beside an + /// internal helper: `applyMigration` compares against exactly what a caller + /// reads, so the two cannot drift into different ideas of where a namespace + /// that has applied nothing is. + /// + /// `applyMigration` reaches it as `head(msg.sender)`, which can never be the + /// zero address, so the refusal is redundant on that path. It is one + /// function, so it is one refusal, and the reachable path is the one it is + /// there for. + function head(address writer) public view returns (bytes32) { if (writer == address(0)) { revert ZeroWriter(); } - return readHead(writer); + bytes32 storedHead = sHead[writer]; + return storedHead == bytes32(0) ? MIGRATION_HEAD_GENESIS : storedHead; } } diff --git a/src/generated/candidate/MigrationRegistry.sol b/src/generated/candidate/MigrationRegistry.sol index 341a19a..0ef9798 100644 --- a/src/generated/candidate/MigrationRegistry.sol +++ b/src/generated/candidate/MigrationRegistry.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(0x6ec72be90febf29858c6ed3725ce83ac069b4144535c8893bd3812ec01ec147f); +bytes32 constant BYTECODE_HASH = bytes32(0x10624d7ac73d3b4e379fc0af77347144e32f78808fb51795ac7b2613d2f4df53); /// @dev The deterministic deploy address of the contract when deployed via /// the Zoltu factory. -address constant DEPLOYED_ADDRESS = address(0xD30aB9571a185e5c883C7b0e30f4D5560009BaFC); +address constant DEPLOYED_ADDRESS = address(0x6E3aE74aDCd6CF28A1b2F685D5E709ffE44D429D); /// @dev The creation bytecode of the contract. bytes constant CREATION_CODE = - hex"6080604052348015600e575f80fd5b506104b08061001c5f395ff3fe608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063bda4fec514610043578063cda1c3b914610068578063e29304161461007d575b5f80fd5b61005661005136600461044f565b610090565b60405190815260200160405180910390f35b61007b610076366004610468565b6100ed565b005b61005661008b366004610488565b6102bc565b5f73ffffffffffffffffffffffffffffffffffffffff82166100de576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100e7826103cd565b92915050565b80610124576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f810361017d576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f90815260208181526040808320848452909152902054156101da576040517f09ffc700000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101e4336103cd565b905080831461022f576040517facbe685200000000000000000000000000000000000000000000000000000000815233600482015260248101849052604481018290526064016101d1565b425f03610268576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff831661030a576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b81610341576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f820361039a576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526001602052604081205480156103fe5780610420565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff8116811461044a575f80fd5b919050565b5f6020828403121561045f575f80fd5b61042082610427565b5f8060408385031215610479575f80fd5b50508035926020909101359150565b5f8060408385031215610499575f80fd5b6104a283610427565b94602093909301359350505056"; + hex"6080604052348015600e575f80fd5b506104a18061001c5f395ff3fe608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063a56d39e014610043578063bda4fec514610058578063e29304161461007d575b5f80fd5b610056610051366004610418565b610090565b005b61006b610066366004610460565b61025f565b60405190815260200160405180910390f35b61006b61008b366004610479565b610307565b806100c7576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f8103610120576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f908152602081815260408083208484529091529020541561017d576040517fd242f96f000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101873361025f565b90508083146101d2576040517facbe68520000000000000000000000000000000000000000000000000000000081523360048201526024810184905260448101829052606401610174565b425f0361020b576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff82166102ad576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b73ffffffffffffffffffffffffffffffffffffffff82165f9081526001602052604090205480156102de5780610300565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b5f73ffffffffffffffffffffffffffffffffffffffff8316610355576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8161038c576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f82036103e5576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b5f8060408385031215610429575f80fd5b50508035926020909101359150565b803573ffffffffffffffffffffffffffffffffffffffff8116811461045b575f80fd5b919050565b5f60208284031215610470575f80fd5b61030082610438565b5f806040838503121561048a575f80fd5b61049383610438565b94602093909301359350505056"; /// @dev The runtime bytecode of the contract. bytes constant RUNTIME_CODE = - hex"608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063bda4fec514610043578063cda1c3b914610068578063e29304161461007d575b5f80fd5b61005661005136600461044f565b610090565b60405190815260200160405180910390f35b61007b610076366004610468565b6100ed565b005b61005661008b366004610488565b6102bc565b5f73ffffffffffffffffffffffffffffffffffffffff82166100de576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100e7826103cd565b92915050565b80610124576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f810361017d576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f90815260208181526040808320848452909152902054156101da576040517f09ffc700000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101e4336103cd565b905080831461022f576040517facbe685200000000000000000000000000000000000000000000000000000000815233600482015260248101849052604481018290526064016101d1565b425f03610268576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff831661030a576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b81610341576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f820361039a576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526001602052604081205480156103fe5780610420565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff8116811461044a575f80fd5b919050565b5f6020828403121561045f575f80fd5b61042082610427565b5f8060408385031215610479575f80fd5b50508035926020909101359150565b5f8060408385031215610499575f80fd5b6104a283610427565b94602093909301359350505056"; + hex"608060405234801561000f575f80fd5b506004361061003f575f3560e01c8063a56d39e014610043578063bda4fec514610058578063e29304161461007d575b5f80fd5b610056610051366004610418565b610090565b005b61006b610066366004610460565b61025f565b60405190815260200160405180910390f35b61006b61008b366004610479565b610307565b806100c7576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f8103610120576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f908152602081815260408083208484529091529020541561017d576040517fd242f96f000000000000000000000000000000000000000000000000000000008152336004820152602481018290526044015b60405180910390fd5b5f6101873361025f565b90508083146101d2576040517facbe68520000000000000000000000000000000000000000000000000000000081523360048201526024810184905260448101829052606401610174565b425f0361020b576040517fda16d76700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b335f8181526020818152604080832086845282528083204290558383526001909152808220859055518492917f4b783664c1bcc23d06e2e5633252a3fce6e0d115df1940913aecf8082b893de191a3505050565b5f73ffffffffffffffffffffffffffffffffffffffff82166102ad576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b73ffffffffffffffffffffffffffffffffffffffff82165f9081526001602052604090205480156102de5780610300565b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f5b9392505050565b5f73ffffffffffffffffffffffffffffffffffffffff8316610355576040517f895c0eb100000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8161038c576040517f7704c9bc00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b7fd85a7f14bb19a07649810ac09029cc9c29667e4059cb83e258bc52e213365d3f82036103e5576040517f86ba6ff800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b5073ffffffffffffffffffffffffffffffffffffffff919091165f90815260208181526040808320938352929052205490565b5f8060408385031215610429575f80fd5b50508035926020909101359150565b803573ffffffffffffffffffffffffffffffffffffffff8116811461045b575f80fd5b919050565b5f60208284031215610470575f80fd5b61030082610438565b5f806040838503121561048a575f80fd5b61049383610438565b94602093909301359350505056"; diff --git a/src/interface/IMigrationRegistryV1.sol b/src/interface/IMigrationRegistryV1.sol index 7d72d97..ce74447 100644 --- a/src/interface/IMigrationRegistryV1.sol +++ b/src/interface/IMigrationRegistryV1.sol @@ -2,12 +2,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity ^0.8.25; -/// @dev The head of a namespace that has never recorded a migration. A writer's -/// first `record` names this, and every later one names the migration before it. +/// @dev The head of a namespace that has never applied a migration. A writer's +/// first `applyMigration` names this, and every later one names the migration +/// before it. /// /// It is deliberately NOT zero. Zero is what an uninitialised `bytes32` constant /// reads as, and a genesis of zero would make an uninitialised predecessor -/// constant a SUCCESSFUL first record on any namespace that happens to be empty +/// constant a SUCCESSFUL first application on any namespace that happens to be +/// empty /// — which is the state of every namespace on every chain the consumer has not /// migrated yet, i.e. exactly where a mis-set constant is most likely and most /// expensive. Under a nonzero genesis that same constant is a revert in every @@ -20,9 +22,9 @@ pragma solidity ^0.8.25; /// deterministic address. /// /// It is not a migration, and an implementation MUST refuse it as one. A head -/// holds exactly two kinds of value: a recorded migration, or this. Letting a -/// migration BE this would put a namespace that has recorded something at a head -/// indistinguishable from one that has recorded nothing, which is the same +/// holds exactly two kinds of value: an applied migration, or this. Letting a +/// migration BE this would put a namespace that has applied something at a head +/// indistinguishable from one that has applied nothing, which is the same /// collapse of two distinct facts into one value that `ZeroMigration` exists to /// refuse — the two values a head can hold that are not migrations are exactly /// the two values a migration id may not be. @@ -30,12 +32,12 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// @title IMigrationRegistryV1 /// @notice A per-writer record of which migrations have been applied and when, -/// with exactly three operations: a writer records one of its own migrations -/// onto the head it believes its namespace is at (`record`), anyone reads when a -/// given writer recorded a given migration (`applied`), and anyone reads where a -/// given writer's namespace currently is (`head`). There is no removal, no -/// upgrade and no authority beyond the writer over its own namespace, and an -/// implementation MUST NOT add any. +/// with exactly three operations: a writer applies one of its own migrations +/// onto the head it believes its namespace is at (`applyMigration`), anyone +/// reads when a given writer applied a given migration (`applied`), and anyone +/// reads where a given writer's namespace currently is (`head`). There is no +/// removal, no upgrade and no authority beyond the writer over its own +/// namespace, and an implementation MUST NOT add any. /// /// It exists so that a test can decide what to assert by reading what happened /// on chain rather than by reading the clock. Without it, a test that spans a @@ -57,7 +59,7 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// actually holds. Replacing the pins with this registry trades a clock-guess /// for a bookkeeping-guess, which is not an improvement. An implementation MUST /// NOT offer anything that invites it, and in particular MUST NOT record -/// anything about the state a migration produced — only that it was recorded, +/// anything about the state a migration produced — only that it was applied, /// and when. /// /// The timestamp is a fact about the RECORD, not about the state: it is the @@ -66,7 +68,7 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// not become proof of anything, for the same reason reading the record back /// does not. /// -/// ## `applied` answers WHEN, and zero still means "not recorded" +/// ## `applied` answers WHEN, and zero still means "not applied" /// /// `applied` is a timestamp rather than a flag because "which invariant applies" /// is frequently "which invariant applies YET": a migration that starts a @@ -74,7 +76,7 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// needs the moment to go and find the log for it, or — worse — to go back to /// the deadline constant this registry exists to delete. /// -/// A migration nobody recorded answers zero, and that is an ANSWER rather than a +/// A migration nobody applied answers zero, and that is an ANSWER rather than a /// revert: it is the ordinary state of every migration before it runs and of /// every migration on a chain that never got it, and it is the branch a caller /// asserts the pre-migration state in. Zero and nonzero are therefore the same @@ -88,11 +90,11 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// /// ## The head is what makes an ordered sequence ordered /// -/// A namespace has a HEAD: the migration most recently recorded under it, or -/// `MIGRATION_HEAD_GENESIS` if it has never recorded one. `record` takes the -/// head the caller believes its namespace is at and refuses to write unless that -/// is where the namespace actually is; on success the recorded migration becomes -/// the new head. +/// A namespace has a HEAD: the migration most recently applied under it, or +/// `MIGRATION_HEAD_GENESIS` if it has never applied one. `applyMigration` takes +/// the head the caller believes its namespace is at and refuses to write unless +/// that is where the namespace actually is; on success the applied migration +/// becomes the new head. /// /// This is what blocks a SKIPPED step. A migration script names its predecessor, /// so a chain that never got the predecessor is a loud revert at the moment of @@ -101,21 +103,21 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// from landing in whichever order the mempool chose: the second one names a /// head that has moved. /// -/// It does NOT block a DUPLICATE, and `MigrationAlreadyRecorded` is not -/// redundant beside it. Re-recording a migration whose successor has since +/// It does NOT block a DUPLICATE, and `MigrationAlreadyApplied` is not +/// redundant beside it. Re-applying a migration whose successor has since /// landed presents a head that matches perfectly, and would move the head /// BACKWARDS and overwrite the original timestamp — a record un-happening, which /// is the one thing this registry promises cannot occur. The two refusals answer /// two different questions: the head is about WHERE in the sequence a caller is, -/// and the already-recorded refusal is about WHETHER this particular migration +/// and the already-applied refusal is about WHETHER this particular migration /// has run at all. /// /// One namespace on one chain is therefore ONE linear sequence, and that is a /// consequence to design around rather than an implementation detail. Two /// unrelated sets of migrations applied from the same account on the same chain /// interleave into one chain of heads, so each script's expected head is -/// whatever that account last recorded rather than whatever that script's own -/// author had in mind. A consumer that wants two independent sequences records +/// whatever that account last applied rather than whatever that script's own +/// author had in mind. A consumer that wants two independent sequences applies /// them from two accounts, which is the same lever that already decides who a /// reader trusts. /// @@ -148,22 +150,22 @@ bytes32 constant MIGRATION_HEAD_GENESIS = keccak256("rain.migration-registry.hea /// reserves: zero, which is what an empty namespace holds before it is read as /// genesis, and `MIGRATION_HEAD_GENESIS`, which is what it reads as. Neither can /// be a migration without a head losing the ability to say whether a namespace -/// has recorded anything. Two callers agreeing on any other id is entirely their +/// has applied anything. Two callers agreeing on any other id is entirely their /// business. /// /// The convention that suits scripts-as-migrations is the hash of the script's /// identity, e.g. `keccak256("script/20260623-upgrade-receipt-vaults.s.sol")`. /// A date alone is not enough: two migrations authored on one day collide, and /// consumers do author two on one day. An id is fixed at the moment it is first -/// recorded, so a script renamed afterwards keeps the id it was recorded under +/// applied, so a script renamed afterwards keeps the id it was applied under /// rather than acquiring a new one — which is why the id belongs in a named /// constant beside the script, not derived from a path at the call site. /// /// A head is an id, so the same is true of the head a script names: it is the /// predecessor's named constant, imported, not a second spelling of it. interface IMigrationRegistryV1 { - /// Thrown when `record` is called with the zero migration id, and by - /// `applied` when it is asked about one. The zero id is what an + /// Thrown when `applyMigration` is called with the zero migration id, and + /// by `applied` when it is asked about one. The zero id is what an /// uninitialised `bytes32` constant reads as, and an uninitialised id is /// never a migration anybody meant to name. Rejected in both directions /// because the read is the dangerous one: answering zero would silently @@ -171,16 +173,17 @@ interface IMigrationRegistryV1 { /// /// There is no matching refusal for a zero HEAD, and adding one would be a /// guard on something already impossible: a head is either - /// `MIGRATION_HEAD_GENESIS` or a recorded id, both nonzero, so a zero head + /// `MIGRATION_HEAD_GENESIS` or an applied id, both nonzero, so a zero head /// can never match and is already refused by `UnexpectedMigrationHead` — /// which names the zero it was handed, so nothing about the mistake is lost. error ZeroMigration(); - /// Thrown when `record` is called with `MIGRATION_HEAD_GENESIS` as the - /// migration, and by `applied` when it is asked about it. Genesis is a head, - /// not a migration: recording it would leave a namespace that has recorded - /// something at a head no different from one that has recorded nothing, and - /// asking `applied` about it would answer zero forever for a caller that has + /// Thrown when `applyMigration` is called with `MIGRATION_HEAD_GENESIS` as + /// the migration, and by `applied` when it is asked about it. Genesis is a + /// head, not a migration: applying it would leave a namespace that has + /// applied something at a head no different from one that has applied + /// nothing, and asking `applied` about it would answer zero forever for a + /// caller that has /// confused a head for a migration and will read that as its pre-migration /// branch. /// @@ -192,15 +195,15 @@ interface IMigrationRegistryV1 { /// Thrown by `applied` and `head` when asked about the zero writer. No /// transaction can originate from the zero address, so the zero namespace is - /// provably empty and the answer would always be "nothing recorded, at + /// provably empty and the answer would always be "nothing applied, at /// genesis" — an unresolved or unset writer constant would therefore read as /// a pristine namespace rather than as the mistake it is. /// - /// There is no matching case on `record`: `msg.sender` is never zero, so - /// the zero namespace cannot be written to in the first place. + /// There is no matching case on `applyMigration`: `msg.sender` is never + /// zero, so the zero namespace cannot be written to in the first place. error ZeroWriter(); - /// Thrown when a writer records a migration it has already recorded. This + /// Thrown when a writer applies a migration it has already applied. This /// is what makes running a migration twice structurally impossible rather /// than a warning in a workflow dropdown asking a human not to re-dispatch /// it: a script consults `applied` before it acts, and this is the backstop @@ -211,10 +214,10 @@ interface IMigrationRegistryV1 { /// script is told the migration already ran, rather than told the namespace /// has moved on and left to work out why. /// @param writer The namespace, which is the caller. - /// @param migration The migration already recorded under it. - error MigrationAlreadyRecorded(address writer, bytes32 migration); + /// @param migration The migration already applied under it. + error MigrationAlreadyApplied(address writer, bytes32 migration); - /// Thrown when a writer records onto a head its namespace is not at. Either + /// Thrown when a writer applies onto a head its namespace is not at. Either /// something the caller believed had been applied has not been, or something /// it did not know about has been — a skipped predecessor, a concurrent /// dispatch that landed first, or a chain that is simply further behind than @@ -224,14 +227,15 @@ interface IMigrationRegistryV1 { /// @param actualHead The head the namespace is actually at. error UnexpectedMigrationHead(address writer, bytes32 expectedHead, bytes32 actualHead); - /// Thrown when `record` is called in a block whose timestamp is zero. A - /// record IS its timestamp, so a zero one would read back through `applied` - /// as no record at all, while the head moved and the migration cannot be - /// re-recorded — the worst of every branch at once. Refusing to write is the - /// only outcome that leaves the namespace describing something true. + /// Thrown when `applyMigration` is called in a block whose timestamp is + /// zero. A record IS its timestamp, so a zero one would read back through + /// `applied` as no record at all, while the head moved and the migration + /// cannot be re-applied — the worst of every branch at once. Refusing to + /// write is the only outcome that leaves the namespace describing something + /// true. error ZeroTimestamp(); - /// Emitted every time a migration is recorded. A migration is recorded at + /// Emitted every time a migration is applied. A migration is applied at /// most once per writer, so the log is the complete history of the registry /// and the only way to discover a record without already knowing the id. /// @@ -241,15 +245,14 @@ interface IMigrationRegistryV1 { /// was applied onto, and the first was applied onto /// `MIGRATION_HEAD_GENESIS`. The timestamp is the block's. /// @param writer The namespace, which is the caller. - /// @param migration The migration recorded. + /// @param migration The migration applied. event Migrated(address indexed writer, bytes32 indexed migration); - /// Records `migration` as applied under the caller's namespace, onto - /// `expectedHead`. + /// Applies `migration` under the caller's namespace, onto `expectedHead`. /// /// The implementation MUST revert `ZeroMigration` if `migration` is zero, /// `GenesisMigration` if it is `MIGRATION_HEAD_GENESIS`, - /// `MigrationAlreadyRecorded` if the caller has already recorded it, + /// `MigrationAlreadyApplied` if the caller has already applied it, /// `UnexpectedMigrationHead` if the caller's namespace is not at /// `expectedHead`, and `ZeroTimestamp` if `block.timestamp` is zero. It MUST /// NOT provide any way to unrecord a migration or to move a head backwards. @@ -259,10 +262,10 @@ interface IMigrationRegistryV1 { /// Nothing is returned: the new head is the `migration` just passed in and /// the timestamp is the block's, so both are already in the caller's hand. /// - /// A caller SHOULD record the migration in the same atomic unit as the - /// migration itself where it can — a Safe appends this call to the bundle - /// it is already executing — so that the record and the change it describes - /// cannot land apart. Where they cannot be atomic, record LAST: a record + /// A caller SHOULD call this in the same atomic unit as the migration + /// itself where it can — a Safe appends this call to the bundle it is + /// already executing — so that the record and the change it describes + /// cannot land apart. Where they cannot be atomic, call it LAST: a record /// that never landed leaves a reader asserting the pre-migration state, /// which the verification layer then catches loudly, and leaves a re-run /// possible. A record that landed for a migration that did not is the @@ -270,16 +273,16 @@ interface IMigrationRegistryV1 { /// @param expectedHead The head the caller believes its namespace is at: /// the migration it is applying onto, or `MIGRATION_HEAD_GENESIS` for the /// first migration in a namespace. Never zero, which can never match. - /// @param migration The migration to record. Never zero, never + /// @param migration The migration to apply. Never zero, never /// `MIGRATION_HEAD_GENESIS`. - function record(bytes32 expectedHead, bytes32 migration) external; + function applyMigration(bytes32 expectedHead, bytes32 migration) external; - /// When `writer` recorded `migration`, as the timestamp of the block the + /// When `writer` applied `migration`, as the timestamp of the block the /// record landed in. Zero if it never did. /// /// The implementation MUST revert `ZeroWriter`, `ZeroMigration` or /// `GenesisMigration` rather than answering about any of them, and MUST - /// answer zero — not revert — for a real writer that has simply not recorded + /// answer zero — not revert — for a real writer that has simply not applied /// a real migration. /// /// That zero is the deliberate difference from a registry whose reads revert @@ -290,17 +293,17 @@ interface IMigrationRegistryV1 { /// revert there would leave a caller with nothing to say about the state it /// is actually looking at, which is the whole failure this registry removes. /// - /// Zero is unambiguous because `record` refuses to write a zero timestamp, - /// so no recorded migration can present as an unrecorded one. + /// Zero is unambiguous because `applyMigration` refuses to write a zero + /// timestamp, so no applied migration can present as an unapplied one. /// @param writer The namespace to read. Never the zero address. /// @param migration The migration to ask about. Never zero, never /// `MIGRATION_HEAD_GENESIS`. - /// @return The block timestamp `writer` recorded `migration` at, or zero if + /// @return The block timestamp `writer` applied `migration` at, or zero if /// it has not. function applied(address writer, bytes32 migration) external view returns (uint256); - /// Where `writer`'s namespace currently is: the migration it recorded most - /// recently, or `MIGRATION_HEAD_GENESIS` if it has never recorded one. + /// Where `writer`'s namespace currently is: the migration it applied most + /// recently, or `MIGRATION_HEAD_GENESIS` if it has never applied one. /// /// The implementation MUST revert `ZeroWriter` rather than answering about /// the zero namespace, and MUST NEVER answer zero — an empty namespace is diff --git a/src/lib/LibMigrationRegistry.sol b/src/lib/LibMigrationRegistry.sol index cfb88e3..6223898 100644 --- a/src/lib/LibMigrationRegistry.sol +++ b/src/lib/LibMigrationRegistry.sol @@ -13,8 +13,8 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// 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 answers when a writer recorded a migration and -/// where that writer's namespace has got to, and it records one under the +/// That is the whole library. It answers when a writer applied a migration and +/// where that writer's namespace has got to, and it applies one under the /// caller. Which writer a test trusts, which invariant each answer selects, and /// how an id is derived are entirely the consumer's business and none of this /// library's. @@ -24,13 +24,13 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// `LibRainDeploy` wraps broadcasting because a deploy is always a broadcast. /// A migration is not: the dominant real shape is a Safe executing a bundle, /// where the script emits transactions for the multisig to sign and never -/// broadcasts anything itself. Such a script appends `record` to the bundle it -/// is already emitting, which is what makes the record atomic with the +/// broadcasts anything itself. Such a script appends `applyMigration` to the +/// bundle it is already emitting, which is what makes the record atomic with the /// migration it describes — a property no runner in this library could offer, /// and one a runner would quietly compete with. /// -/// So `record` is an ordinary call. A broadcasting EOA script wraps it in its -/// own `vm.startBroadcast`, a Safe bundle appends it, and a test calls it +/// So `applyMigration` is an ordinary call. A broadcasting EOA script wraps it +/// in its own `vm.startBroadcast`, a Safe bundle appends it, and a test calls it /// directly; none of those is privileged over the others here. /// /// ## Reading is what this is for @@ -59,9 +59,10 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// /// ## Writing names the head it is applying onto /// -/// `record` takes the migration the caller believes ran last in its namespace, -/// so a chain that never got that predecessor refuses the write instead of -/// silently skipping a step, and two migrations dispatched at once cannot land +/// `applyMigration` takes the migration the caller believes ran last in its +/// namespace, so a chain that never got that predecessor refuses the write +/// instead of silently skipping a step, and two migrations dispatched at once +/// cannot land /// in the wrong order. The first migration in a namespace names /// `MIGRATION_HEAD_GENESIS`, imported from the interface — never a zero, which /// is what an uninitialised constant would be and is refused everywhere. @@ -99,7 +100,7 @@ library LibMigrationRegistry { } } - /// When `writer` recorded `migration`, or zero if it never did. + /// When `writer` applied `migration`, or zero if it never did. /// /// 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 @@ -116,7 +117,7 @@ library LibMigrationRegistry { /// caller trusts. Never the zero address. /// @param migration The migration to ask about. Never zero, never /// `MIGRATION_HEAD_GENESIS`. - /// @return The block timestamp `writer` recorded `migration` at, or zero if + /// @return The block timestamp `writer` applied `migration` at, or zero if /// it has not. function applied(address writer, bytes32 migration) internal view returns (uint256) { checkCodeHash(); @@ -125,8 +126,8 @@ library LibMigrationRegistry { .applied(writer, migration); } - /// The migration `writer` recorded most recently, or `MIGRATION_HEAD_GENESIS` - /// if it has never recorded one. + /// The migration `writer` applied most recently, or `MIGRATION_HEAD_GENESIS` + /// if it has never applied one. /// /// Verifies the registry's code hash first for the same reason `applied` /// does, and more sharply: a call into an empty account returns nothing, @@ -140,33 +141,33 @@ library LibMigrationRegistry { return IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS).head(writer); } - /// Records `migration` under the CALLER's namespace, onto `expectedHead`. + /// Applies `migration` under the CALLER's namespace, onto `expectedHead`. /// /// The caller is whoever the resulting transaction is sent from — a Safe /// executing a bundle, a broadcasting EOA, a timelock — and that account is /// the namespace the record lands in. A reader has to ask about that same - /// account, so which account a migration is recorded from is a decision + /// account, so which account a migration is applied from is a decision /// with a consequence rather than an implementation detail. It is also the - /// account whose head this moves, so two unrelated sequences recorded from + /// account whose head this moves, so two unrelated sequences applied from /// one account interleave into one chain. /// /// Verifies the registry's code hash before writing, so a migration is - /// never "recorded" into an empty address or into unknown code. A record + /// never "applied" into an empty address or into unknown code. A record /// that went nowhere is worse than no record at all: the migration would /// have run, and every reader would go on asserting the pre-migration /// state. /// /// The registry refuses the zero id, refuses a migration this caller has - /// already recorded, and refuses one applied onto anything but the + /// already applied, and refuses one applied onto anything but the /// namespace's actual head — which between them make a re-dispatched, a /// skipped and an out-of-order migration all fail rather than land. - /// @param expectedHead The migration the caller believes it recorded last, + /// @param expectedHead The migration the caller believes it applied last, /// or `MIGRATION_HEAD_GENESIS` for the first in this namespace. - /// @param migration The migration to record. Never zero, never + /// @param migration The migration to apply. Never zero, never /// `MIGRATION_HEAD_GENESIS`. - function record(bytes32 expectedHead, bytes32 migration) internal { + function applyMigration(bytes32 expectedHead, bytes32 migration) internal { checkCodeHash(); IMigrationRegistryV1(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS) - .record(expectedHead, migration); + .applyMigration(expectedHead, migration); } } diff --git a/test/concrete/MockMigrationRecorder.sol b/test/concrete/MockMigrationApplier.sol similarity index 67% rename from test/concrete/MockMigrationRecorder.sol rename to test/concrete/MockMigrationApplier.sol index 4d2d0b5..0db70b0 100644 --- a/test/concrete/MockMigrationRecorder.sol +++ b/test/concrete/MockMigrationApplier.sol @@ -4,10 +4,10 @@ pragma solidity =0.8.25; import {LibMigrationRegistry} from "../../src/lib/LibMigrationRegistry.sol"; -/// @title MockMigrationRecorder -/// @notice A consumer in the shape `LibMigrationRegistry.record` is designed -/// for: it calls the library and nothing else, so the record lands under THIS -/// contract's address. +/// @title MockMigrationApplier +/// @notice A consumer in the shape `LibMigrationRegistry.applyMigration` is +/// designed for: it calls the library and nothing else, so the record lands +/// under THIS contract's address. /// /// It exists so the namespace can be exercised as the property it is. The /// library's functions are `internal` and inline into whatever executes them, @@ -18,20 +18,20 @@ import {LibMigrationRegistry} from "../../src/lib/LibMigrationRegistry.sol"; /// asserted about a single account. /// /// Two of them are also two heads, which is what makes "one namespace is one -/// sequence" checkable at all: nothing about one recorder's head can be shown +/// sequence" checkable at all: nothing about one applier's head can be shown /// to leave the other's alone from inside a single namespace. -contract MockMigrationRecorder { - /// Records `migration` under this contract, onto `expectedHead`. +contract MockMigrationApplier { + /// Applies `migration` under this contract, onto `expectedHead`. /// @param expectedHead The head this contract believes it is at. - /// @param migration The migration to record. - function record(bytes32 expectedHead, bytes32 migration) external { - LibMigrationRegistry.record(expectedHead, migration); + /// @param migration The migration to apply. + function applyMigration(bytes32 expectedHead, bytes32 migration) external { + LibMigrationRegistry.applyMigration(expectedHead, migration); } - /// When `writer` recorded `migration`. + /// When `writer` applied `migration`. /// @param writer The namespace to read. /// @param migration The migration to ask about. - /// @return The timestamp it was recorded at, or zero. + /// @return The timestamp it was applied at, or zero. function applied(address writer, bytes32 migration) external view returns (uint256) { return LibMigrationRegistry.applied(writer, migration); } diff --git a/test/src/concrete/MigrationRegistryApplied.t.sol b/test/src/concrete/MigrationRegistryApplied.t.sol index 3246a16..6a66a92 100644 --- a/test/src/concrete/MigrationRegistryApplied.t.sol +++ b/test/src/concrete/MigrationRegistryApplied.t.sol @@ -8,8 +8,8 @@ import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interfa import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; /// @title MigrationRegistryAppliedTest -/// @notice A test suite for `MigrationRegistry.applied`: it answers a recorded -/// migration with the moment it was recorded, an unrecorded one with zero, +/// @notice A test suite for `MigrationRegistry.applied`: it answers an applied +/// migration with the moment it was applied, an unapplied one with zero, /// refuses the three inputs that can only be mistakes, and is the only reader of /// the records. contract MigrationRegistryAppliedTest is Test { @@ -27,38 +27,38 @@ contract MigrationRegistryAppliedTest is Test { vm.assume(migration != MIGRATION_HEAD_GENESIS); } - /// An unrecorded migration answers zero rather than reverting. This is + /// An unapplied migration answers zero rather than reverting. This is /// the deliberate difference from a registry whose reads revert on an /// unknown key: "not applied here" is the ordinary state of every migration /// before it runs and of every migration on a chain that never got it, and /// it is the answer a caller branches on to assert the pre-migration state /// exactly. A revert would leave the caller with nothing to say about the /// state it is actually looking at. - function testAppliedUnrecordedIsZero(address writer, bytes32 migration) external view { + function testAppliedUnappliedIsZero(address writer, bytes32 migration) external view { vm.assume(writer != address(0)); assumeMigration(migration); assertEq(sRegistry.applied(writer, migration), 0); } - /// A recorded migration answers the timestamp of the block it was recorded + /// An applied migration answers the timestamp of the block it was applied /// in, and keeps answering it as time moves on. The value is when the /// migration was applied, not how long ago or how recently anything was /// asked. - function testAppliedIsTheRecordingTimestamp(address writer, bytes32 migration, uint32 recordedAt, uint32 readAt) + function testAppliedIsTheApplicationTimestamp(address writer, bytes32 migration, uint32 appliedAt, uint32 readAt) external { vm.assume(writer != address(0)); assumeMigration(migration); - vm.assume(recordedAt != 0); - vm.assume(readAt >= recordedAt); + vm.assume(appliedAt != 0); + vm.assume(readAt >= appliedAt); - vm.warp(recordedAt); + vm.warp(appliedAt); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.warp(readAt); - assertEq(sRegistry.applied(writer, migration), recordedAt); + assertEq(sRegistry.applied(writer, migration), appliedAt); } /// Reading does not consume or alter a record, so the same question asked @@ -68,7 +68,7 @@ contract MigrationRegistryAppliedTest is Test { assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), block.timestamp); assertEq(sRegistry.applied(writer, migration), block.timestamp); @@ -87,7 +87,8 @@ contract MigrationRegistryAppliedTest is Test { } /// The zero migration id is refused for the same reason in the other - /// direction: `record` will not write it, so it can never be a real record. + /// direction: `applyMigration` will not write it, so it can never be a real + /// record. function testAppliedZeroMigrationReverts(address writer) external { vm.assume(writer != address(0)); @@ -96,8 +97,9 @@ contract MigrationRegistryAppliedTest is Test { } /// The genesis head is refused as a migration for the same reason again: - /// `record` will not write it either, so asking about it would answer zero - /// forever to a caller that has confused a head for a migration — and that + /// `applyMigration` will not write it either, so asking about it would + /// answer zero forever to a caller that has confused a head for a migration + /// — and that /// caller reads zero as its pre-migration branch. function testAppliedGenesisMigrationReverts(address writer) external { vm.assume(writer != address(0)); @@ -125,7 +127,7 @@ contract MigrationRegistryAppliedTest is Test { assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); sRegistry.applied(address(0), migration); @@ -163,7 +165,7 @@ contract MigrationRegistryAppliedTest is Test { /// selector reverts instead of being silently absorbed. function testAppliedNoOtherEntryPoint(bytes4 selector, bytes32 migration) external { vm.assume(selector != IMigrationRegistryV1.applied.selector); - vm.assume(selector != IMigrationRegistryV1.record.selector); + vm.assume(selector != IMigrationRegistryV1.applyMigration.selector); vm.assume(selector != IMigrationRegistryV1.head.selector); (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, address(this), migration)); diff --git a/test/src/concrete/MigrationRegistryRecord.t.sol b/test/src/concrete/MigrationRegistryApplyMigration.t.sol similarity index 66% rename from test/src/concrete/MigrationRegistryRecord.t.sol rename to test/src/concrete/MigrationRegistryApplyMigration.t.sol index f5b7c17..22681ed 100644 --- a/test/src/concrete/MigrationRegistryRecord.t.sol +++ b/test/src/concrete/MigrationRegistryApplyMigration.t.sol @@ -7,11 +7,11 @@ import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; -/// @title MigrationRegistryRecordTest -/// @notice A test suite for `MigrationRegistry.record`: who a record belongs -/// to, that a migration is recorded at most once and only onto the head its -/// caller named, what a record carries, and what it may never become. -contract MigrationRegistryRecordTest is Test { +/// @title MigrationRegistryApplyMigrationTest +/// @notice A test suite for `MigrationRegistry.applyMigration`: who a record +/// belongs to, that a migration is applied at most once and only onto the head +/// its caller named, what a record carries, and what it may never become. +contract MigrationRegistryApplyMigrationTest is Test { /// The registry under test. Stateful, so a fresh one per test. MigrationRegistry internal sRegistry; @@ -27,15 +27,15 @@ contract MigrationRegistryRecordTest is Test { vm.assume(migration != MIGRATION_HEAD_GENESIS); } - /// Anyone may record, and the record lands under the caller. There is no + /// Anyone may apply, and the record lands under the caller. There is no /// authority to be refused by, which is the whole access-control design: /// the namespace IS the caller. - function testRecordAnyCallerRecordsUnderItself(address writer, bytes32 migration) external { + function testApplyMigrationAnyCallerAppliesUnderItself(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), block.timestamp); } @@ -44,22 +44,22 @@ contract MigrationRegistryRecordTest is Test { /// difference from a flag: a consumer whose invariant starts AT the /// migration — a cliff, a rate change, a grace period — reads the moment /// from the chain rather than from a constant somebody guessed. - function testRecordStoresTheBlockTimestamp(address writer, bytes32 migration, uint32 timestamp) external { + function testApplyMigrationStoresTheBlockTimestamp(address writer, bytes32 migration, uint32 timestamp) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.assume(timestamp != 0); vm.warp(timestamp); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), timestamp); } - /// Two migrations recorded in different blocks carry different timestamps, + /// Two migrations applied in different blocks carry different timestamps, /// and the earlier one does not move when the later one lands. A record is /// of the moment it happened, not of the last time anything happened. - function testRecordTimestampsAreIndependent(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationTimestampsAreIndependent(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -67,11 +67,11 @@ contract MigrationRegistryRecordTest is Test { vm.warp(1000); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.warp(2000); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); assertEq(sRegistry.applied(writer, migrationA), 1000); assertEq(sRegistry.applied(writer, migrationB), 2000); @@ -79,88 +79,88 @@ contract MigrationRegistryRecordTest is Test { /// A record refuses to be written at all in a block whose timestamp is zero, /// rather than write one that `applied` would read back as no record. The - /// head does not move and the migration stays recordable, which is the only - /// outcome that leaves the namespace describing something true. - function testRecordZeroTimestampReverts(address writer, bytes32 migration) external { + /// head does not move and the migration can still be applied, which is the + /// only outcome that leaves the namespace describing something true. + function testApplyMigrationZeroTimestampReverts(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.warp(0); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroTimestamp.selector)); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), 0); assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); vm.warp(1); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), 1); } - /// A record is confined to the caller's namespace. Recording under one + /// A record is confined to the caller's namespace. Applying under one /// writer says nothing about any other, which is what makes a reader's /// choice of namespace the whole of who it trusts — a hostile caller can - /// record whatever it likes and reach nobody. - function testRecordDoesNotReachAnotherNamespace(address writer, address other, bytes32 migration) external { + /// apply whatever it likes and reach nobody. + function testApplyMigrationDoesNotReachAnotherNamespace(address writer, address other, bytes32 migration) external { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), block.timestamp); assertEq(sRegistry.applied(other, migration), 0); } - /// Two writers may record the same migration id independently, and each + /// Two writers may apply the same migration id independently, and each /// answers only for itself. Ids are opaque and namespaces are unrelated, so /// a shared id is not a collision — including for the head, which each /// writer advances from its own genesis. - function testRecordSameMigrationUnderTwoWriters(address writer, address other, bytes32 migration) external { + function testApplyMigrationSameMigrationUnderTwoWriters(address writer, address other, bytes32 migration) external { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.prank(other); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), block.timestamp); assertEq(sRegistry.applied(other, migration), block.timestamp); } - /// Migrations are independent within one namespace: recording one says + /// Migrations are independent within one namespace: applying one says /// nothing about any other. This is what a set buys over a high-water mark /// — a reader asks about the migration its assertion actually depends on /// rather than about a number that stands in for all of them. - function testRecordDistinctMigrations(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationDistinctMigrations(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); assertEq(sRegistry.applied(writer, migrationA), block.timestamp); assertEq(sRegistry.applied(writer, migrationB), 0); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); assertEq(sRegistry.applied(writer, migrationA), block.timestamp); assertEq(sRegistry.applied(writer, migrationB), block.timestamp); } - /// A successful record makes its migration the namespace's new head, which - /// is what the next one has to name. - function testRecordAdvancesTheHead(address writer, bytes32 migrationA, bytes32 migrationB) external { + /// A successful application makes its migration the namespace's new head, + /// which is what the next one has to name. + function testApplyMigrationAdvancesTheHead(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -169,19 +169,19 @@ contract MigrationRegistryRecordTest is Test { assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); assertEq(sRegistry.head(writer), migrationA); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); assertEq(sRegistry.head(writer), migrationB); } - /// A record onto a head the namespace is not at is refused. This is what + /// Applying onto a head the namespace is not at is refused. This is what /// blocks a SKIPPED step: a script names its predecessor, so a chain that /// never got that predecessor fails at the moment of applying rather than /// diverging silently from every chain that did. - function testRecordSkippedPredecessorReverts( + function testApplyMigrationSkippedPredecessorReverts( address writer, bytes32 migrationA, bytes32 migrationB, @@ -196,29 +196,29 @@ contract MigrationRegistryRecordTest is Test { vm.assume(skipped != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.expectRevert( abi.encodeWithSelector(IMigrationRegistryV1.UnexpectedMigrationHead.selector, writer, skipped, migrationA) ); vm.prank(writer); - sRegistry.record(skipped, migrationB); + sRegistry.applyMigration(skipped, migrationB); assertEq(sRegistry.applied(writer, migrationB), 0); assertEq(sRegistry.head(writer), migrationA); } - /// Genesis stops being an acceptable head the moment anything is recorded, + /// Genesis stops being an acceptable head the moment anything is applied, /// so a first-migration script re-run against a namespace that has moved on /// fails rather than restarting the sequence. - function testRecordOntoGenesisAfterFirstReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationOntoGenesisAfterFirstReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.expectRevert( abi.encodeWithSelector( @@ -226,13 +226,13 @@ contract MigrationRegistryRecordTest is Test { ) ); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationB); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationB); } /// A head belongs to one namespace. One writer advancing its head leaves /// every other writer's exactly where it was, so a second consumer's /// migrations are not blocked or unblocked by the first's. - function testRecordHeadIsPerWriter(address writer, address other, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationHeadIsPerWriter(address writer, address other, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); @@ -241,7 +241,7 @@ contract MigrationRegistryRecordTest is Test { vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); assertEq(sRegistry.head(other), MIGRATION_HEAD_GENESIS); @@ -253,20 +253,20 @@ contract MigrationRegistryRecordTest is Test { ) ); vm.prank(other); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); vm.prank(other); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationB); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationB); assertEq(sRegistry.head(other), migrationB); assertEq(sRegistry.head(writer), migrationA); } /// A zero head never matches anything, including on a namespace that has - /// recorded nothing — which is the whole reason genesis is not zero. An + /// applied nothing — which is the whole reason genesis is not zero. An /// uninitialised predecessor constant is a revert in every namespace state, - /// rather than a successful first record on every chain that happens to be + /// rather than a successful first application on every chain that happens to be /// empty. - function testRecordZeroHeadRevertsOnEmptyNamespace(address writer, bytes32 migration) external { + function testApplyMigrationZeroHeadRevertsOnEmptyNamespace(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); @@ -276,20 +276,20 @@ contract MigrationRegistryRecordTest is Test { ) ); vm.prank(writer); - sRegistry.record(bytes32(0), migration); + sRegistry.applyMigration(bytes32(0), migration); assertEq(sRegistry.applied(writer, migration), 0); } - /// And on a namespace that has recorded something. - function testRecordZeroHeadRevertsOnUsedNamespace(address writer, bytes32 migrationA, bytes32 migrationB) external { + /// And on a namespace that has applied something. + function testApplyMigrationZeroHeadRevertsOnUsedNamespace(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.expectRevert( abi.encodeWithSelector( @@ -297,34 +297,34 @@ contract MigrationRegistryRecordTest is Test { ) ); vm.prank(writer); - sRegistry.record(bytes32(0), migrationB); + sRegistry.applyMigration(bytes32(0), migrationB); } - /// Recording twice is refused. This is what makes running a migration twice - /// fail rather than repeat: a re-dispatched script cannot quietly record + /// Applying twice is refused. This is what makes running a migration twice + /// fail rather than repeat: a re-dispatched script cannot quietly apply /// its way to looking like a first run. - function testRecordTwiceReverts(address writer, bytes32 migration) external { + function testApplyMigrationTwiceReverts(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert( - abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyApplied.selector, writer, migration) ); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(writer, migration), block.timestamp); } - /// The head does NOT subsume the already-recorded refusal. Re-recording a + /// The head does NOT subsume the already-applied refusal. Re-applying a /// migration whose successor has since landed presents a head that matches /// perfectly, and is still refused — otherwise the head would move BACKWARDS /// and the original timestamp would be overwritten, which is a record /// un-happening. - function testRecordAgainOnMatchingHeadReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationAgainOnMatchingHeadReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -332,29 +332,29 @@ contract MigrationRegistryRecordTest is Test { vm.warp(1000); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); // The namespace really is at `migrationB`, so the head this names is - // correct and only the already-recorded refusal can stop it. + // correct and only the already-applied refusal can stop it. assertEq(sRegistry.head(writer), migrationB); vm.warp(2000); vm.expectRevert( - abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migrationA) + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyApplied.selector, writer, migrationA) ); vm.prank(writer); - sRegistry.record(migrationB, migrationA); + sRegistry.applyMigration(migrationB, migrationA); assertEq(sRegistry.head(writer), migrationB); assertEq(sRegistry.applied(writer, migrationA), 1000); } - /// The already-recorded refusal is checked BEFORE the head, so a + /// The already-applied refusal is checked BEFORE the head, so a /// re-dispatched script — which names the same head it named the first time, /// long since moved on — is told that its migration already ran rather than /// told the namespace is somewhere else and left to work out why. - function testRecordAlreadyRecordedCheckedBeforeHead(address writer, bytes32 migrationA, bytes32 migrationB) + function testApplyMigrationAlreadyAppliedCheckedBeforeHead(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); @@ -363,73 +363,73 @@ contract MigrationRegistryRecordTest is Test { vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); vm.expectRevert( - abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migrationA) + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyApplied.selector, writer, migrationA) ); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); } - /// A migration another writer has already recorded is still a FIRST record + /// A migration another writer has already applied is still a FIRST record /// for this one. The refusal is per namespace, not global, or one consumer /// choosing a common id would lock every other consumer out of it. - function testRecordTwiceIsPerWriter(address writer, address other, bytes32 migration) external { + function testApplyMigrationTwiceIsPerWriter(address writer, address other, bytes32 migration) external { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.prank(other); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.applied(other, migration), block.timestamp); } /// The zero migration id is refused. It is what an uninitialised `bytes32` - /// constant reads as, and there is deliberately no way to record one, which + /// constant reads as, and there is deliberately no way to apply one, which /// is what lets `applied` refuse it as a mistake rather than have to answer /// about it. - function testRecordZeroMigrationReverts(address writer) external { + function testApplyMigrationZeroMigrationReverts(address writer) external { vm.assume(writer != address(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, bytes32(0)); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, bytes32(0)); } - /// The zero id is refused BEFORE the already-recorded read and before the + /// The zero id is refused BEFORE the already-applied read and before the /// head, so it is always reported as `ZeroMigration` and never as anything /// about where the namespace is. - function testRecordZeroMigrationCheckedFirst(address writer, bytes32 anyHead) external { + function testApplyMigrationZeroMigrationCheckedFirst(address writer, bytes32 anyHead) external { vm.assume(writer != address(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(anyHead, bytes32(0)); + sRegistry.applyMigration(anyHead, bytes32(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(anyHead, bytes32(0)); + sRegistry.applyMigration(anyHead, bytes32(0)); } - /// Genesis is a head, not a migration, and recording it is refused. It would + /// Genesis is a head, not a migration, and applying it is refused. It would /// otherwise leave the namespace's head holding the exact value an empty - /// namespace reads as, so a namespace that had recorded something would be + /// namespace reads as, so a namespace that had applied something would be /// indistinguishable from one that had not — and the next first-migration /// script would be accepted against it. - function testRecordGenesisMigrationReverts(address writer) external { + function testApplyMigrationGenesisMigrationReverts(address writer) external { vm.assume(writer != address(0)); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); } @@ -441,9 +441,10 @@ contract MigrationRegistryRecordTest is Test { /// told which of the two it got wrong rather than sent to look at where the /// namespace has got to. /// - /// Fuzzed over the head for the same reason `testRecordZeroMigrationCheckedFirst` - /// is: a matching head alone cannot tell the two orderings apart. - function testRecordGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration, bytes32 anyHead) external { + /// Fuzzed over the head for the same reason + /// `testApplyMigrationZeroMigrationCheckedFirst` is: a matching head alone + /// cannot tell the two orderings apart. + function testApplyMigrationGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration, bytes32 anyHead) external { vm.assume(writer != address(0)); assumeMigration(migration); @@ -451,34 +452,34 @@ contract MigrationRegistryRecordTest is Test { // that does not match it. vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); - sRegistry.record(anyHead, MIGRATION_HEAD_GENESIS); + sRegistry.applyMigration(anyHead, MIGRATION_HEAD_GENESIS); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); // A namespace that has moved: same refusal, onto the head it is at and // onto any other. vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); - sRegistry.record(migration, MIGRATION_HEAD_GENESIS); + sRegistry.applyMigration(migration, MIGRATION_HEAD_GENESIS); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); - sRegistry.record(anyHead, MIGRATION_HEAD_GENESIS); + sRegistry.applyMigration(anyHead, MIGRATION_HEAD_GENESIS); assertEq(sRegistry.head(writer), migration); } /// Ids are opaque: nothing about a migration's bytes changes how it is /// stored or read, including ids no hashing convention would produce. - function testRecordOpaqueMigrationIds(address writer) external { + function testApplyMigrationOpaqueMigrationIds(address writer) external { vm.assume(writer != address(0)); bytes32[2] memory migrations = [bytes32(uint256(1)), bytes32(type(uint256).max)]; for (uint256 i = 0; i < migrations.length; i++) { MigrationRegistry registry = new MigrationRegistry(); vm.prank(writer); - registry.record(MIGRATION_HEAD_GENESIS, migrations[i]); + registry.applyMigration(MIGRATION_HEAD_GENESIS, migrations[i]); assertEq(registry.applied(writer, migrations[i]), block.timestamp); assertEq(registry.head(writer), migrations[i]); } @@ -491,13 +492,13 @@ contract MigrationRegistryRecordTest is Test { /// It carries no head and no timestamp because the log already holds both: /// one writer's entries in order ARE its chain of heads, and the timestamp /// is the block's. - function testRecordEvent(address writer, bytes32 migration) external { + function testApplyMigrationEvent(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.recordLogs(); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); Vm.Log[] memory entries = vm.getRecordedLogs(); assertEq(entries.length, 1); @@ -509,34 +510,34 @@ contract MigrationRegistryRecordTest is Test { assertEq(entries[0].data.length, 0); } - /// A refused `record` emits nothing, so a failed record can never be + /// A refused `applyMigration` emits nothing, so a failed apply can never be /// mistaken for a record by anything reading the logs — which for a /// re-dispatched migration is exactly the mistake that matters. - function testRecordNoEventOnRevert(address writer, bytes32 migration) external { + function testApplyMigrationNoEventOnRevert(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.recordLogs(); vm.expectRevert( - abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, writer, migration) + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyApplied.selector, writer, migration) ); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(vm.getRecordedLogs().length, 0); vm.recordLogs(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); vm.prank(writer); - sRegistry.record(migration, bytes32(0)); + sRegistry.applyMigration(migration, bytes32(0)); assertEq(vm.getRecordedLogs().length, 0); vm.recordLogs(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); vm.prank(writer); - sRegistry.record(migration, MIGRATION_HEAD_GENESIS); + sRegistry.applyMigration(migration, MIGRATION_HEAD_GENESIS); assertEq(vm.getRecordedLogs().length, 0); vm.recordLogs(); @@ -546,7 +547,7 @@ contract MigrationRegistryRecordTest is Test { ) ); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, keccak256(abi.encode(migration))); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, keccak256(abi.encode(migration))); assertEq(vm.getRecordedLogs().length, 0); } } diff --git a/test/src/concrete/MigrationRegistryHead.t.sol b/test/src/concrete/MigrationRegistryHead.t.sol index 9cb3eb6..37bca98 100644 --- a/test/src/concrete/MigrationRegistryHead.t.sol +++ b/test/src/concrete/MigrationRegistryHead.t.sol @@ -10,7 +10,7 @@ import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; /// @title MigrationRegistryHeadTest /// @notice A test suite for `MigrationRegistry.head`: where a namespace is, what /// an empty one answers, that the answer is never a value that is not a head, -/// and that it is the same answer `record` checks against. +/// and that it is the same answer `applyMigration` checks against. contract MigrationRegistryHeadTest is Test { /// The registry under test. Stateful, so a fresh one per test. MigrationRegistry internal sRegistry; @@ -26,8 +26,8 @@ contract MigrationRegistryHeadTest is Test { vm.assume(migration != MIGRATION_HEAD_GENESIS); } - /// A namespace that has recorded nothing is at genesis, which is an ANSWER - /// rather than a revert for the same reason an unrecorded migration answers + /// A namespace that has applied nothing is at genesis, which is an ANSWER + /// rather than a revert for the same reason an unapplied migration answers /// zero: it is the ordinary state of every namespace before its first /// migration, and of every namespace on a chain that never got one. function testHeadEmptyNamespaceIsGenesis(address writer) external view { @@ -44,7 +44,7 @@ contract MigrationRegistryHeadTest is Test { assertTrue(MIGRATION_HEAD_GENESIS != bytes32(0)); } - /// The head is the migration recorded most recently, and it moves with each + /// The head is the migration applied most recently, and it moves with each /// one. function testHeadFollowsTheRecords(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); @@ -53,11 +53,11 @@ contract MigrationRegistryHeadTest is Test { vm.assume(migrationA != migrationB); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); assertEq(sRegistry.head(writer), migrationA); vm.prank(writer); - sRegistry.record(migrationA, migrationB); + sRegistry.applyMigration(migrationA, migrationB); assertEq(sRegistry.head(writer), migrationB); } @@ -70,17 +70,17 @@ contract MigrationRegistryHeadTest is Test { assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertEq(sRegistry.head(writer), migration); assertEq(sRegistry.head(other), MIGRATION_HEAD_GENESIS); } - /// The head `head` reports is exactly the head `record` demands: whatever - /// this answers is accepted, and it is the only value that is. The two go - /// through one translation of an empty namespace, so they cannot disagree - /// about where one is. - function testHeadIsWhatRecordAccepts(address writer, bytes32 migrationA, bytes32 migrationB) external { + /// The head `head` reports is exactly the head `applyMigration` demands: + /// whatever this answers is accepted, and it is the only value that is. The + /// two go through one translation of an empty namespace, so they cannot + /// disagree about where one is. + function testHeadIsWhatApplyMigrationAccepts(address writer, bytes32 migrationA, bytes32 migrationB) external { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -90,11 +90,11 @@ contract MigrationRegistryHeadTest is Test { // head is a call. bytes32 headBeforeA = sRegistry.head(writer); vm.prank(writer); - sRegistry.record(headBeforeA, migrationA); + sRegistry.applyMigration(headBeforeA, migrationA); bytes32 headBeforeB = sRegistry.head(writer); vm.prank(writer); - sRegistry.record(headBeforeB, migrationB); + sRegistry.applyMigration(headBeforeB, migrationB); assertEq(sRegistry.head(writer), migrationB); assertEq(sRegistry.applied(writer, migrationA), block.timestamp); @@ -117,7 +117,7 @@ contract MigrationRegistryHeadTest is Test { assumeMigration(migration); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroWriter.selector)); sRegistry.head(address(0)); @@ -135,15 +135,15 @@ contract MigrationRegistryHeadTest is Test { assertTrue(sRegistry.head(writer) != bytes32(0)); vm.prank(writer); - sRegistry.record(MIGRATION_HEAD_GENESIS, migration); + sRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); assertTrue(sRegistry.head(writer) != bytes32(0)); } - /// A refused record leaves the head where it was. The head moves only for a - /// migration that was actually recorded, so it can never describe a step + /// A refused apply leaves the head where it was. The head moves only for a + /// migration that was actually applied, so it can never describe a step /// that did not happen. - function testHeadUnmovedByRefusedRecord(address writer, bytes32 migration, bytes32 wrongHead) external { + function testHeadUnmovedByRefusedApplyMigration(address writer, bytes32 migration, bytes32 wrongHead) external { vm.assume(writer != address(0)); assumeMigration(migration); assumeMigration(wrongHead); @@ -155,7 +155,7 @@ contract MigrationRegistryHeadTest is Test { ) ); vm.prank(writer); - sRegistry.record(wrongHead, migration); + sRegistry.applyMigration(wrongHead, migration); assertEq(sRegistry.head(writer), MIGRATION_HEAD_GENESIS); } diff --git a/test/src/lib/LibMigrationRegistry.t.sol b/test/src/lib/LibMigrationRegistry.t.sol index fd8f971..2dba619 100644 --- a/test/src/lib/LibMigrationRegistry.t.sol +++ b/test/src/lib/LibMigrationRegistry.t.sol @@ -8,7 +8,7 @@ import {LibMigrationRegistryDeploy} from "../../../src/lib/LibMigrationRegistryD import {LibRainDeploy} from "../../../src/lib/LibRainDeploy.sol"; import {IMigrationRegistryV1, MIGRATION_HEAD_GENESIS} from "../../../src/interface/IMigrationRegistryV1.sol"; import {MigrationRegistry} from "../../../src/concrete/MigrationRegistry.sol"; -import {MockMigrationRecorder} from "../../concrete/MockMigrationRecorder.sol"; +import {MockMigrationApplier} from "../../concrete/MockMigrationApplier.sol"; /// @title LibMigrationRegistryTest /// Tests for `LibMigrationRegistry`. The registry is not mocked: the real @@ -38,7 +38,7 @@ contract LibMigrationRegistryTest is Test { /// correct call depth. /// @param writer The namespace to read. /// @param migration The migration to ask about. - /// @return When `writer` recorded `migration`, or zero. + /// @return When `writer` applied `migration`, or zero. function externalApplied(address writer, bytes32 migration) external view returns (uint256) { return LibMigrationRegistry.applied(writer, migration); } @@ -51,12 +51,12 @@ contract LibMigrationRegistryTest is Test { return LibMigrationRegistry.head(writer); } - /// External wrapper for `record` so that `vm.expectRevert` works at the - /// correct call depth. + /// External wrapper for `applyMigration` so that `vm.expectRevert` works at + /// the correct call depth. /// @param expectedHead The head this contract believes it is at. - /// @param migration The migration to record. - function externalRecord(bytes32 expectedHead, bytes32 migration) external { - LibMigrationRegistry.record(expectedHead, migration); + /// @param migration The migration to apply. + function externalApplyMigration(bytes32 expectedHead, bytes32 migration) external { + LibMigrationRegistry.applyMigration(expectedHead, migration); } /// The Zoltu deploy really does land the registry on its pinned address @@ -72,11 +72,11 @@ contract LibMigrationRegistryTest is Test { ); } - /// An unrecorded migration answers zero. This is the branch a caller + /// An unapplied migration answers zero. This is the branch a caller /// asserts the pre-migration state in, and it is the ordinary state of /// every migration that has not run, so it is an answer rather than a /// revert. - function testAppliedUnrecordedIsZero(address writer, bytes32 migration) external { + function testAppliedUnappliedIsZero(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); deployRegistry(); @@ -84,20 +84,21 @@ contract LibMigrationRegistryTest is Test { assertEq(LibMigrationRegistry.applied(writer, migration), 0); } - /// A recorded migration answers the moment it was recorded — read back - /// through the library, so what `record` writes is what `applied` finds. - function testRecordThenApplied(bytes32 migration, uint32 recordedAt) external { + /// An applied migration answers the moment it was applied — read back + /// through the library, so what `applyMigration` writes is what `applied` + /// finds. + function testApplyMigrationThenApplied(bytes32 migration, uint32 appliedAt) external { assumeMigration(migration); - vm.assume(recordedAt != 0); + vm.assume(appliedAt != 0); deployRegistry(); - vm.warp(recordedAt); + vm.warp(appliedAt); - LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migration); + LibMigrationRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); - assertEq(LibMigrationRegistry.applied(address(this), migration), recordedAt); + assertEq(LibMigrationRegistry.applied(address(this), migration), appliedAt); } - /// A namespace that has recorded nothing reads back as genesis, and each + /// A namespace that has applied nothing reads back as genesis, and each /// record moves the head to itself. This is the value the next migration /// has to name, so it is read through the library rather than assumed. function testHeadFollowsTheRecords(bytes32 migrationA, bytes32 migrationB) external { @@ -108,17 +109,17 @@ contract LibMigrationRegistryTest is Test { assertEq(LibMigrationRegistry.head(address(this)), MIGRATION_HEAD_GENESIS); - LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migrationA); + LibMigrationRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migrationA); assertEq(LibMigrationRegistry.head(address(this)), migrationA); - LibMigrationRegistry.record(migrationA, migrationB); + LibMigrationRegistry.applyMigration(migrationA, migrationB); assertEq(LibMigrationRegistry.head(address(this)), migrationB); } /// A migration applied onto a head this namespace is not at is refused, and /// the registry's own revert arrives unmodified. This is a skipped step /// failing at the moment of applying rather than a chain quietly diverging. - function testRecordSkippedPredecessorReverts(bytes32 migration, bytes32 skipped) external { + function testApplyMigrationSkippedPredecessorReverts(bytes32 migration, bytes32 skipped) external { assumeMigration(migration); assumeMigration(skipped); deployRegistry(); @@ -128,7 +129,7 @@ contract LibMigrationRegistryTest is Test { IMigrationRegistryV1.UnexpectedMigrationHead.selector, address(this), skipped, MIGRATION_HEAD_GENESIS ) ); - this.externalRecord(skipped, migration); + this.externalApplyMigration(skipped, migration); assertEq(LibMigrationRegistry.applied(address(this), migration), 0); } @@ -138,14 +139,14 @@ contract LibMigrationRegistryTest is Test { /// the registry sees that caller as `msg.sender` — which means a consumer /// chooses its namespace by choosing what sends the transaction, and cannot /// write anybody else's. - function testRecordLandsUnderTheCallingContract(bytes32 migration) external { + function testApplyMigrationLandsUnderTheCallingContract(bytes32 migration) external { assumeMigration(migration); deployRegistry(); - MockMigrationRecorder recorder = new MockMigrationRecorder(); + MockMigrationApplier applier = new MockMigrationApplier(); - recorder.record(MIGRATION_HEAD_GENESIS, migration); + applier.applyMigration(MIGRATION_HEAD_GENESIS, migration); - assertEq(LibMigrationRegistry.applied(address(recorder), migration), block.timestamp); + assertEq(LibMigrationRegistry.applied(address(applier), migration), block.timestamp); assertEq(LibMigrationRegistry.applied(address(this), migration), 0); } @@ -153,49 +154,51 @@ contract LibMigrationRegistryTest is Test { /// for itself — heads included, so one consumer's sequence neither blocks /// nor unblocks another's. This is the whole of the access control: a /// reader's choice of writer is the whole of who it trusts. - function testRecordDoesNotReachAnotherNamespace(bytes32 migration) external { + function testApplyMigrationDoesNotReachAnotherNamespace(bytes32 migration) external { assumeMigration(migration); deployRegistry(); - MockMigrationRecorder recorder = new MockMigrationRecorder(); - MockMigrationRecorder other = new MockMigrationRecorder(); + MockMigrationApplier applier = new MockMigrationApplier(); + MockMigrationApplier other = new MockMigrationApplier(); - recorder.record(MIGRATION_HEAD_GENESIS, migration); + applier.applyMigration(MIGRATION_HEAD_GENESIS, migration); - assertEq(other.applied(address(recorder), migration), block.timestamp); + assertEq(other.applied(address(applier), migration), block.timestamp); assertEq(other.applied(address(other), migration), 0); - assertEq(other.head(address(recorder)), migration); + assertEq(other.head(address(applier)), migration); assertEq(other.head(address(other)), MIGRATION_HEAD_GENESIS); } - /// Recording the same migration twice is refused, and the registry's own + /// Applying the same migration twice is refused, and the registry's own /// revert arrives unmodified — the library adds no handling of its own, so /// a re-dispatched migration fails naming the writer and the id. - function testRecordTwiceReverts(bytes32 migration) external { + function testApplyMigrationTwiceReverts(bytes32 migration) external { assumeMigration(migration); deployRegistry(); - LibMigrationRegistry.record(MIGRATION_HEAD_GENESIS, migration); + LibMigrationRegistry.applyMigration(MIGRATION_HEAD_GENESIS, migration); vm.expectRevert( - abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyRecorded.selector, address(this), migration) + abi.encodeWithSelector(IMigrationRegistryV1.MigrationAlreadyApplied.selector, address(this), migration) ); - this.externalRecord(MIGRATION_HEAD_GENESIS, migration); + this.externalApplyMigration(MIGRATION_HEAD_GENESIS, migration); } - /// The registry's zero-id refusal arrives unmodified through `record`. - function testRecordZeroMigrationReverts() external { + /// The registry's zero-id refusal arrives unmodified through + /// `applyMigration`. + function testApplyMigrationZeroMigrationReverts() external { deployRegistry(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.ZeroMigration.selector)); - this.externalRecord(MIGRATION_HEAD_GENESIS, bytes32(0)); + this.externalApplyMigration(MIGRATION_HEAD_GENESIS, bytes32(0)); } - /// The registry's genesis-id refusal arrives unmodified through `record`. - function testRecordGenesisMigrationReverts() external { + /// The registry's genesis-id refusal arrives unmodified through + /// `applyMigration`. + function testApplyMigrationGenesisMigrationReverts() external { deployRegistry(); vm.expectRevert(abi.encodeWithSelector(IMigrationRegistryV1.GenesisMigration.selector)); - this.externalRecord(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); + this.externalApplyMigration(MIGRATION_HEAD_GENESIS, MIGRATION_HEAD_GENESIS); } /// The registry's zero-writer refusal arrives unmodified through `applied`. @@ -269,11 +272,11 @@ contract LibMigrationRegistryTest is Test { this.externalHead(writer); } - /// Writing to a chain with no registry is refused for the mirror reason: a - /// `record` into an empty account is a migration that reports itself - /// recorded and is not, which leaves every reader asserting the + /// Writing to a chain with no registry is refused for the mirror reason: an + /// `applyMigration` into an empty account is a migration that reports itself + /// applied and is not, which leaves every reader asserting the /// pre-migration state forever. - function testRecordNoRegistry(bytes32 expectedHead, bytes32 migration) external { + function testApplyMigrationNoRegistry(bytes32 expectedHead, bytes32 migration) external { assertEq(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.code.length, 0); vm.expectRevert( @@ -283,7 +286,7 @@ contract LibMigrationRegistryTest is Test { bytes32(0) ) ); - this.externalRecord(expectedHead, migration); + this.externalApplyMigration(expectedHead, migration); } /// A chain where something other than the pinned registry occupies the @@ -320,8 +323,8 @@ contract LibMigrationRegistryTest is Test { this.externalHead(writer); } - /// And never recorded into it either. - function testRecordWrongCode(bytes32 expectedHead, bytes32 migration, bytes memory code) external { + /// And never applied into it either. + function testApplyMigrationWrongCode(bytes32 expectedHead, bytes32 migration, bytes memory code) external { vm.assume(code.length > 0); vm.assume(keccak256(code) != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH); vm.etch(LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS, code); @@ -333,6 +336,6 @@ contract LibMigrationRegistryTest is Test { keccak256(code) ) ); - this.externalRecord(expectedHead, migration); + this.externalApplyMigration(expectedHead, migration); } } From 20d7284b9b452e7675a1b371e338f46d12205bca Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 07:56:45 +0000 Subject: [PATCH 11/11] style(migration-registry): rewrap what the rename left dangling, and fmt The rename left five comment lines orphaned mid-paragraph and pushed six test signatures past the 120-column `forge fmt` limit. `forge fmt --check` is exit 0. Co-Authored-By: Claude Opus 5 (1M context) --- src/interface/IMigrationRegistryV1.sol | 18 ++++++------ src/lib/LibMigrationRegistry.sol | 3 +- .../concrete/MigrationRegistryApplied.t.sol | 3 +- .../MigrationRegistryApplyMigration.t.sol | 28 +++++++++++++------ 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/interface/IMigrationRegistryV1.sol b/src/interface/IMigrationRegistryV1.sol index ce74447..143c9ba 100644 --- a/src/interface/IMigrationRegistryV1.sol +++ b/src/interface/IMigrationRegistryV1.sol @@ -9,13 +9,12 @@ pragma solidity ^0.8.25; /// It is deliberately NOT zero. Zero is what an uninitialised `bytes32` constant /// reads as, and a genesis of zero would make an uninitialised predecessor /// constant a SUCCESSFUL first application on any namespace that happens to be -/// empty -/// — which is the state of every namespace on every chain the consumer has not -/// migrated yet, i.e. exactly where a mis-set constant is most likely and most -/// expensive. Under a nonzero genesis that same constant is a revert in every -/// namespace state, empty or not, for the same reason `ZeroMigration` and -/// `ZeroWriter` exist: an uninitialised value is a mistake to be reported, never -/// a question to be answered. +/// empty — which is the state of every namespace on every chain the consumer +/// has not migrated yet, i.e. exactly where a mis-set constant is most likely +/// and most expensive. Under a nonzero genesis that same constant is a revert +/// in every namespace state, empty or not, for the same reason `ZeroMigration` +/// and `ZeroWriter` exist: an uninitialised value is a mistake to be reported, +/// never a question to be answered. /// /// It is one shared value rather than anything derived per writer or per /// consumer, so it configures nothing and cannot fragment the implementation's @@ -183,9 +182,8 @@ interface IMigrationRegistryV1 { /// head, not a migration: applying it would leave a namespace that has /// applied something at a head no different from one that has applied /// nothing, and asking `applied` about it would answer zero forever for a - /// caller that has - /// confused a head for a migration and will read that as its pre-migration - /// branch. + /// caller that has confused a head for a migration and will read that as + /// its pre-migration branch. /// /// This is the same refusal as `ZeroMigration` under a different diagnosis, /// and they are separate errors because the mistakes are different: a zero diff --git a/src/lib/LibMigrationRegistry.sol b/src/lib/LibMigrationRegistry.sol index 6223898..d5d8e9d 100644 --- a/src/lib/LibMigrationRegistry.sol +++ b/src/lib/LibMigrationRegistry.sol @@ -62,8 +62,7 @@ import {LibMigrationRegistryDeploy} from "./LibMigrationRegistryDeploy.sol"; /// `applyMigration` takes the migration the caller believes ran last in its /// namespace, so a chain that never got that predecessor refuses the write /// instead of silently skipping a step, and two migrations dispatched at once -/// cannot land -/// in the wrong order. The first migration in a namespace names +/// cannot land in the wrong order. The first migration in a namespace names /// `MIGRATION_HEAD_GENESIS`, imported from the interface — never a zero, which /// is what an uninitialised constant would be and is refused everywhere. /// diff --git a/test/src/concrete/MigrationRegistryApplied.t.sol b/test/src/concrete/MigrationRegistryApplied.t.sol index 6a66a92..5d02628 100644 --- a/test/src/concrete/MigrationRegistryApplied.t.sol +++ b/test/src/concrete/MigrationRegistryApplied.t.sol @@ -99,8 +99,7 @@ contract MigrationRegistryAppliedTest is Test { /// The genesis head is refused as a migration for the same reason again: /// `applyMigration` will not write it either, so asking about it would /// answer zero forever to a caller that has confused a head for a migration - /// — and that - /// caller reads zero as its pre-migration branch. + /// — and that caller reads zero as its pre-migration branch. function testAppliedGenesisMigrationReverts(address writer) external { vm.assume(writer != address(0)); diff --git a/test/src/concrete/MigrationRegistryApplyMigration.t.sol b/test/src/concrete/MigrationRegistryApplyMigration.t.sol index 22681ed..760287d 100644 --- a/test/src/concrete/MigrationRegistryApplyMigration.t.sol +++ b/test/src/concrete/MigrationRegistryApplyMigration.t.sol @@ -59,7 +59,9 @@ contract MigrationRegistryApplyMigrationTest is Test { /// Two migrations applied in different blocks carry different timestamps, /// and the earlier one does not move when the later one lands. A record is /// of the moment it happened, not of the last time anything happened. - function testApplyMigrationTimestampsAreIndependent(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationTimestampsAreIndependent(address writer, bytes32 migrationA, bytes32 migrationB) + external + { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -211,7 +213,9 @@ contract MigrationRegistryApplyMigrationTest is Test { /// Genesis stops being an acceptable head the moment anything is applied, /// so a first-migration script re-run against a namespace that has moved on /// fails rather than restarting the sequence. - function testApplyMigrationOntoGenesisAfterFirstReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationOntoGenesisAfterFirstReverts(address writer, bytes32 migrationA, bytes32 migrationB) + external + { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -232,7 +236,9 @@ contract MigrationRegistryApplyMigrationTest is Test { /// A head belongs to one namespace. One writer advancing its head leaves /// every other writer's exactly where it was, so a second consumer's /// migrations are not blocked or unblocked by the first's. - function testApplyMigrationHeadIsPerWriter(address writer, address other, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationHeadIsPerWriter(address writer, address other, bytes32 migrationA, bytes32 migrationB) + external + { vm.assume(writer != address(0)); vm.assume(other != address(0)); vm.assume(writer != other); @@ -264,8 +270,8 @@ contract MigrationRegistryApplyMigrationTest is Test { /// A zero head never matches anything, including on a namespace that has /// applied nothing — which is the whole reason genesis is not zero. An /// uninitialised predecessor constant is a revert in every namespace state, - /// rather than a successful first application on every chain that happens to be - /// empty. + /// rather than a successful first application on every chain that happens + /// to be empty. function testApplyMigrationZeroHeadRevertsOnEmptyNamespace(address writer, bytes32 migration) external { vm.assume(writer != address(0)); assumeMigration(migration); @@ -282,7 +288,9 @@ contract MigrationRegistryApplyMigrationTest is Test { } /// And on a namespace that has applied something. - function testApplyMigrationZeroHeadRevertsOnUsedNamespace(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationZeroHeadRevertsOnUsedNamespace(address writer, bytes32 migrationA, bytes32 migrationB) + external + { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -324,7 +332,9 @@ contract MigrationRegistryApplyMigrationTest is Test { /// perfectly, and is still refused — otherwise the head would move BACKWARDS /// and the original timestamp would be overwritten, which is a record /// un-happening. - function testApplyMigrationAgainOnMatchingHeadReverts(address writer, bytes32 migrationA, bytes32 migrationB) external { + function testApplyMigrationAgainOnMatchingHeadReverts(address writer, bytes32 migrationA, bytes32 migrationB) + external + { vm.assume(writer != address(0)); assumeMigration(migrationA); assumeMigration(migrationB); @@ -444,7 +454,9 @@ contract MigrationRegistryApplyMigrationTest is Test { /// Fuzzed over the head for the same reason /// `testApplyMigrationZeroMigrationCheckedFirst` is: a matching head alone /// cannot tell the two orderings apart. - function testApplyMigrationGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration, bytes32 anyHead) external { + function testApplyMigrationGenesisMigrationRevertsOnAnyHead(address writer, bytes32 migration, bytes32 anyHead) + external + { vm.assume(writer != address(0)); assumeMigration(migration);