Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,8 @@ adds a suite by adding an array entry; the keys reported by a mistyped
fall behind the suites it describes. Keys are checked unique, because the key is
what selects what gets broadcast.

**`src/abstract/RainDeployBroadcast.sol`** — the broadcast. Selects one suite by
**`src/abstract/RainDeployBroadcast.sol`** — the broadcast. Runs the source
anchor over the whole declaration first, then selects one suite by
`DEPLOYMENT_SUITE` and deploys it, before reading `DEPLOYMENT_KEY` so a mistyped
suite fails naming the valid ones rather than on a missing key.
`deployNetworks()` defaults to `supportedNetworks()` and is overridable for
Expand Down Expand Up @@ -339,13 +340,23 @@ 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`) — EVERY candidate's
recorded creation code is `type(X).creationCode`. The only check that catches
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`.
2. **Anchored to source** (`RainDeploySuitesBase`) — EVERY candidate's recorded
creation code is `type(X).creationCode`. The only check that catches 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`.

The only group that is not only a test. It is defined on the DECLARATION, and
`RainDeployBroadcast.run()` calls it before it selects a suite or reads a
key, because the broadcast deploys the recorded bytes and the only other
guard in front of the `CREATE2` — `LibRainDeploy` comparing the recorded
address to what the recorded creation code derives — takes both sides out of
the same generated file. An anchor reachable only from a contract that
inherits `Test` is an anchor the irreversible action does not run, and
`CREATE2` at a zero salt puts the wrong bytes at their own permanent address
on every chain a dispatch reaches.
3. **Anchored to the record** (`RainDeployVerifySnapshot`) — every file in the
append-only `src/generated/<tag>/` tree is declared by a released suite,
matched by the address that file's creation code derives. `releasedSuites()`
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ 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 source group is also the only one that is not only a test. It is defined on
the suite declaration, and the broadcast runs it before it selects a suite or
reads a key. The deploy reads the same recorded bytes, and the only other guard
in front of the `CREATE2` compares the recorded address against what the
recorded creation code derives — both out of the same generated file, so it
catches a stale pin and cannot catch a snapshot of the wrong contract. An anchor
only a test contract could reach would be an anchor the irreversible action does
not run, and `CREATE2` at a zero salt puts the wrong bytes at their own
permanent address on every chain the dispatch reached.

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. A candidate
Expand Down
15 changes: 15 additions & 0 deletions src/abstract/RainDeployBroadcast.sol
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ abstract contract RainDeployBroadcast is RainDeploySuitesBase, Script {

/// Broadcasts the suite `DEPLOYMENT_SUITE` names.
///
/// The source anchor runs FIRST, before a suite is selected and before the
/// key is read. A snapshot the deploy reads and nothing anchors is a deploy
/// of unknown bytes: everything else asked of a snapshot is internal to it,
/// and the recorded-address guard `LibRainDeploy` applies before it forks
/// anything compares two values out of the same generated file. That guard
/// is what catches a stale PIN; only the anchor catches a snapshot of the
/// wrong CONTRACT, and `CREATE2` at a zero salt means the wrong bytes take
/// their own permanent address on every chain this reaches. It is run over
/// the whole declaration rather than over the selected suite because the
/// declaration is what a repo maintains and regenerates as a unit — a
/// dispatch of one suite from a tree where another candidate has gone stale
/// is a tree nobody should be broadcasting from at all.
///
/// The suite is resolved before the key is read, so a mistyped suite fails
/// in seconds listing the valid ones rather than failing on a missing
/// `DEPLOYMENT_KEY` and sending the reader after the wrong thing.
Expand All @@ -75,6 +88,8 @@ abstract contract RainDeployBroadcast is RainDeploySuitesBase, Script {
/// `dependencies` enforces per network, and which a caller satisfies by
/// dispatching in order.
function run() external {
checkCandidatesAnchoredToSource();

DeploySuite memory suite = suiteByName(vm.envOr("DEPLOYMENT_SUITE", string("")));

uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY");
Expand Down
69 changes: 63 additions & 6 deletions src/abstract/RainDeploySuitesBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ error UnknownDeploymentSuite(string requested, string validSuites);
/// ordinary state, and it is the CANDIDATE that the source anchor needs.
error NoDeployCandidates();

/// Thrown when a candidate's recorded creation code is not the creation code
/// this repo currently compiles. Hashes rather than the bytes themselves, which
/// run to tens of kilobytes.
/// @param suite The candidate's key.
/// @param storedCreationCodeHash Hash of the creation code the candidate
/// records.
/// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the
/// contract the candidate claims to be.
error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash);

/// One deployable unit: a named snapshot of one contract.
///
/// `creationCode` is the ONLY input. The Zoltu factory is `CREATE2` over its
Expand Down Expand Up @@ -152,12 +162,12 @@ abstract contract RainDeploySuitesBase {
/// 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.
/// reads the candidates. `allSuites` goes through it, and so does
/// `checkCandidatesAnchoredToSource` — 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();
Expand All @@ -166,6 +176,53 @@ abstract contract RainDeploySuitesBase {
}
}

/// EVERY candidate MUST record the creation code this repo compiles.
///
/// This is the ONLY check that catches a snapshot of the wrong contract.
/// Everything else a snapshot is asked is internal to the snapshot — the
/// recorded address is what the recorded creation code derives, the
/// recorded code hash is what it produces — and a consistent snapshot of
/// the wrong thing satisfies all of it, because the wrong contract's bytes
/// agree with each other perfectly.
///
/// It lives on the DECLARATION rather than on the verification abstract
/// because the broadcast runs it too. `RainDeployBroadcast` deploys the
/// bytes a candidate records, and the only guard between it and the Zoltu
/// factory is `LibRainDeploy`'s recorded-address-against-recorded-creation-
/// code comparison — both sides of which come out of the same generated
/// file, so it proves that file is internally consistent and nothing more.
/// A source anchor reachable only from a test contract is an anchor the
/// irreversible action does not run: broadcasting is `workflow_dispatch` on
/// a ref with no required-green gate, so "CI is red on that ref" is a
/// signal a human may not have read, and CREATE2 at a zero salt puts the
/// wrong bytes at their own permanent address on every chain the dispatch
/// reached. One definition, both callers, no way to deploy past it.
///
/// EVERY candidate, because 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.
///
/// Read through `checkedCandidateSuites` rather than `candidateSuites`: a
/// loop over an empty list passes, so a declaration with no candidate at
/// all would turn this into a green check that asserts nothing.
///
/// Candidates alone, and there is no way to spell an exemption. A released
/// suite is MEANT to diverge from current source — it records bytes that
/// are already on chain — so anchoring one to source asserts something
/// false by design, which is why `DeploySuite` carries no source at all and
/// only `DeployCandidate` does.
function checkCandidatesAnchoredToSource() internal pure {
DeployCandidate[] memory candidates = checkedCandidateSuites();
for (uint256 i = 0; i < candidates.length; i++) {
bytes32 stored = keccak256(candidates[i].snapshot.creationCode);
bytes32 source = keccak256(candidates[i].sourceCreationCode);
if (stored != source) {
revert CandidateSourceMismatch(candidates[i].snapshot.suite, stored, source);
}
}
}

/// Every suite this repo declares: the released ones followed by the
/// candidates. This is the verification set and the deploy registry, which
/// are the same set because they are the same declaration.
Expand Down
58 changes: 13 additions & 45 deletions src/abstract/RainDeployVerifySnapshot.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
pragma solidity ^0.8.25;

import {DerivedDeploy, RainDeployVerifyBase} from "./RainDeployVerifyBase.sol";
import {DeployCandidate, DeploySuite} from "./RainDeploySuitesBase.sol";
import {DeploySuite} from "./RainDeploySuitesBase.sol";
import {LibRainDeploy} from "../lib/LibRainDeploy.sol";
import {LibRainDeploySnapshot} from "../lib/LibRainDeploySnapshot.sol";

Expand All @@ -28,16 +28,6 @@ error StoredCodeHashMismatch(string suite, bytes32 storedCodeHash, bytes32 deriv
/// @param runtimeCodeHash The hash of the runtime code the suite records.
error StoredRuntimeCodeHashMismatch(string suite, bytes32 storedBytecodeHash, bytes32 runtimeCodeHash);

/// Thrown when the candidate's recorded creation code is not the creation code
/// this repo currently compiles. Hashes rather than the bytes themselves, which
/// run to tens of kilobytes.
/// @param suite The candidate's key.
/// @param storedCreationCodeHash Hash of the creation code the candidate
/// records.
/// @param sourceCreationCodeHash Hash of `type(X).creationCode` for the
/// contract the candidate claims to be.
error CandidateSourceMismatch(string suite, bytes32 storedCreationCodeHash, bytes32 sourceCreationCodeHash);

/// Thrown when a file in the frozen record is declared by no released suite.
/// The record is append-only, so this never goes away by itself: a release the
/// declaration missed is a release the chain group never asks about, and the
Expand Down Expand Up @@ -80,6 +70,12 @@ error FrozenSnapshotUnreadable(string path);
/// source, so anchoring one to source asserts something that is false by
/// design.
///
/// That one is not defined here. It lives on `RainDeploySuitesBase`, because
/// `RainDeployBroadcast` runs it before it broadcasts and cannot reach anything
/// on this side — this inherits `Test`. Here it is a test; there it is the last
/// thing standing between a stale generated file and a permanent `CREATE2`
/// address on every chain a dispatch reaches.
///
/// **Anchored to the record.** Every file in the frozen record — the
/// append-only `src/generated/<tag>/` directories — is declared by a released
/// suite. This is the one check that is about the DECLARATION rather than about
Expand Down Expand Up @@ -208,18 +204,6 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase {
}
}

/// Checks the candidate against the source this repo compiles.
/// @param candidate The candidate to check.
function checkAnchoredToSource(DeployCandidate memory candidate) internal pure {
if (keccak256(candidate.snapshot.creationCode) != keccak256(candidate.sourceCreationCode)) {
revert CandidateSourceMismatch(
candidate.snapshot.suite,
keccak256(candidate.snapshot.creationCode),
keccak256(candidate.sourceCreationCode)
);
}
}

/// Every declared suite MUST be internally consistent: what it records is
/// what its own creation code derives.
function testSnapshotInternallyConsistent() external {
Expand All @@ -229,32 +213,16 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase {
}
}

/// 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.
/// The check itself is `RainDeploySuitesBase.checkCandidatesAnchoredToSource`
/// rather than anything here, because `RainDeployBroadcast` runs the same
/// definition before it broadcasts. A second spelling on this side is a
/// spelling the deploy does not run, which is exactly the state this test
/// would otherwise be reporting green about.
function testSnapshotMatchesSource() external pure {
checkCandidatesAnchoredToSource(checkedCandidateSuites());
checkCandidatesAnchoredToSource();
}

/// Every release in the frozen record MUST be declared, so that the set the
Expand Down
12 changes: 9 additions & 3 deletions test/abstract/ExternalDeploySuites.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ 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.
/// @notice Every reader of the declaration, 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,
/// Here rather than on each fixture because every fixture needs all of them,
/// 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.
Expand All @@ -33,4 +34,9 @@ abstract contract ExternalDeploySuites is RainDeploySuitesBase {
function externalCheckedCandidateSuites() external pure returns (DeployCandidate[] memory) {
return checkedCandidateSuites();
}

/// Runs the source anchor over the fixture's own declaration.
function externalCheckCandidatesAnchoredToSource() external pure {
checkCandidatesAnchoredToSource();
}
}
Loading
Loading