Skip to content

Commit efa0a78

Browse files
Merge branch 'main' into 2026-08-15-issue-46-freeze-tests
Resolved LibRainDeploySnapshot around #114 and #96: - kept #114's single spelling of the record root (LIB_FS_ROOT, declared above its first use) and layered this branch's root-taking dirForSnapshot overload on top, so the no-root overload reads LIB_FS_ROOT rather than a literal. - freeze now takes a record root, so the monotonicity guard #96 added reads that same root instead of a hardcoded LIB_FS_ROOT: the record a release is appended to is the record it is checked against. testFreezeChecksTheRecordItIsAppendingTo pins it. - both sides' additions to LibRainDeploySnapshot.t.sol kept in full. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents d11484f + 66fc680 commit efa0a78

23 files changed

Lines changed: 1089 additions & 107 deletions

.github/workflows/package-release.yaml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
name: Package Release
2-
# Deploy repo: a manual `sol-v*` tag is the sole release trigger. This repo now
3-
# carries a deployed concrete (`AddressRegistry`) whose address + codehash
4-
# consumers pin, which is exactly the shape rainix-tag-release exists for and
5-
# exactly the shape rainix-autopublish's merge-driven, next-version lifecycle is
6-
# wrong for: autopublish bumps [package].version on every merge while the frozen
7-
# deploy tag only advances at deploy time.
2+
# Deploy repo: a manual `sol-v*` tag is the sole release trigger. This repo
3+
# carries deployed concretes (`AddressRegistry`, `MigrationRegistry`) whose
4+
# address + codehash consumers pin, which is exactly the shape
5+
# rainix-tag-release exists for and exactly the shape rainix-autopublish's
6+
# merge-driven, next-version lifecycle is wrong for: autopublish bumps
7+
# [package].version on every merge while the frozen deploy tag only advances at
8+
# deploy time.
89
#
910
# The tag names the version; rainix-tag-release runs `cutRelease()`, which
1011
# freezes src/generated/<tag>/ AND regenerates the released-suites lib that

.soldeerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
.audit
23
.coderabbitai.yaml
34
.git
45
.github

CLAUDE.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ environment management.
2424
# Enter the nix dev shell (provides forge and all tooling)
2525
nix develop
2626

27+
# Install the dependencies declared in foundry.toml (dependencies/ is
28+
# gitignored, so this is required on a fresh checkout before anything builds)
29+
nix develop -c forge soldeer install
30+
2731
# Build
2832
nix develop -c forge build
2933

@@ -316,7 +320,7 @@ purpose: the suites and the broadcast are both inherited. Run only via the
316320

317321
**`src/abstract/RegistryDeploySuites.sol`** — this repo's own declaration, one
318322
named candidate per deployed registry, inherited by `script/Deploy.sol`,
319-
`script/Build.sol` and both pins test contracts.
323+
`script/Build.sol`, the pins test contracts and `GeneratedSnapshotShapeTest`.
320324

321325
**`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every
322326
deploy repo inherits instead of hand-writing.
@@ -411,8 +415,10 @@ expected addresses, expected code hashes, and dependency lists.
411415
network before anything migrates onto it.
412416
- **Deploy-repo lifecycle**: a manual `sol-v*` tag is the sole release trigger
413417
(`rainix-tag-release`), because this repo carries deployed concretes whose
414-
pins consumers rely on. `[package].version` is the LAST released version and
415-
moves only in lockstep with its snapshots.
418+
pins consumers rely on. `[package].version` is the version of the LAST Soldeer
419+
publish, and it moves only in lockstep with the frozen `src/generated/<tag>/`
420+
record that release writes. A version published before this lifecycle has no
421+
such record.
416422
- **Deploy, then verify, then tag** — in that order, and they are three separate
417423
things. `script/Deploy.sol` broadcasts the suite `DEPLOYMENT_SUITE` names to
418424
every network in `supportedNetworks()`, dispatched by hand through

README.md

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ Approach:
3333
- Hard guards against deploying to networks where dependencies are missing.
3434
- Pre-calculated addresses asserted against the creation code before deploying,
3535
and against the chain after: silent failures fail loudly.
36-
- Bytecode integrity checks (e.g. via the Rain Extrospection lib) supported
37-
post-deploy.
36+
- Bytecode integrity checks post-deploy: `codehash` is compared against the
37+
recorded pin on every network by `RainDeployVerifyChain`, and before every
38+
registry access by `LibAddressRegistry` and `LibMigrationRegistry`.
3839
- An address registry, read at run time rather than compiled into creation code,
3940
and a post-deploy check that every target network's deployment took the
4041
address it was supposed to.
@@ -61,10 +62,10 @@ abstract contract MyDeploySuites is RainDeploySuitesBase {
6162
// script/Deploy.sol
6263
contract Deploy is MyDeploySuites, RainDeployBroadcast {}
6364
64-
// test/src/concrete/MyDeploySnapshot.t.sol
65+
// test/src/abstract/MyDeploySnapshot.t.sol
6566
contract MyDeploySnapshotTest is MyDeploySuites, RainDeployVerifySnapshot {}
6667
67-
// test/src/concrete/MyDeployChain.t.sol
68+
// test/src/abstract/MyDeployChain.t.sol
6869
contract MyDeployChainTest is MyDeploySuites, RainDeployVerifyChain {}
6970
```
7071

@@ -307,10 +308,12 @@ Three separate steps, in this order. Nothing automatic ever broadcasts.
307308

308309
This is a deploy repo: it carries deployed concretes whose addresses and
309310
codehashes consumers pin, so releases are **manual `sol-v*` tags**, not merges.
310-
`[package].version` is the LAST released version, naming the current
311-
`src/generated/<tag>/` snapshots, and only a release moves it. Every version
312-
published under the previous merge-driven lifecycle stays published; consumers
313-
pin exact versions and are unaffected.
311+
`[package].version` is the version of the LAST Soldeer publish, and only a
312+
release moves it. A release cut under this lifecycle also names the frozen
313+
`src/generated/<tag>/` record `cutRelease()` wrote for it. Every version
314+
published under the previous merge-driven lifecycle predates that record and has
315+
none, so `src/generated/` holds no directory for it; those versions stay
316+
published, and consumers pin exact versions and are unaffected.
314317

315318
## Install
316319

@@ -321,12 +324,13 @@ forge soldeer install rain-deploy~<version>
321324
```
322325

323326
**You also need `forge-std` 1.16.1**, remapped as `forge-std-1.16.1/`. The
324-
published package deliberately ships only `src/` and `script/` — no
325-
`remappings.txt`, no `soldeer.lock`, no `dependencies/` — so a consumer resolves
326-
`forge-std` itself. The requirement is transitive rather than incidental: the
327-
deployed contract imports nothing outside this package, but every abstract a
328-
consumer inherits pulls forge-std in — `Script` via `RainDeployBroadcast`,
329-
`Test` via `RainDeployVerifyBase`, and `Vm` via `LibRainDeploy` beneath both:
327+
published package ships `src/`, `script/` and the licence and README files — no
328+
`test/`, no `foundry.toml`, no `remappings.txt`, no `soldeer.lock`, no
329+
`dependencies/` — so a consumer resolves `forge-std` itself. The requirement is
330+
transitive rather than incidental: the deployed contract imports nothing outside
331+
this package, but every abstract a consumer inherits pulls forge-std in —
332+
`Script` via `RainDeployBroadcast`, `Test` via `RainDeployVerifyBase`, and `Vm`
333+
via `LibRainDeploy` beneath both:
330334

331335
```toml
332336
[dependencies]
@@ -361,7 +365,7 @@ Use the nix-pinned `forge` for all development.
361365

362366
## License
363367

364-
DecentraLicense 1.0 (DCL-1.0) — full text in
368+
DecentraLicense 1.0 (SPDX: `LicenseRef-DCL-1.0`) — full text in
365369
[`LICENSES/`](LICENSES/LicenseRef-DCL-1.0.txt). Roughly `CAL-1.0`
366370
([opensource.org](https://opensource.org/license/cal-1-0)) plus user-data
367371
disclosure obligations consistent with permissionless-blockchain assumptions.

foundry.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
[package]
22
name = "rain-deploy"
3-
# Deploy repo: this is the LAST released version, naming the current
4-
# src/generated/<tag>/ snapshot, not a next-version slot. A normal PR does not
5-
# bump it; only a `sol-v*` tag release moves it, in lockstep with the snapshot.
3+
# Deploy repo: this is the version of the LAST Soldeer publish, not a
4+
# next-version slot. A normal PR does not bump it; only a `sol-v*` tag release
5+
# moves it, in lockstep with the frozen src/generated/<tag>/ record that release
6+
# writes. A version published before this lifecycle has no such record, so
7+
# src/generated/ holds no directory for it.
68
version = "0.1.5"
79

810
# SPDX-License-Identifier: LicenseRef-DCL-1.0
@@ -68,6 +70,10 @@ polygon = "${POLYGON_RPC_URL}"
6870
# these variable names, so a deploy without this section broadcasts and then
6971
# fails with no API key configured for the chain — after spending the gas. One
7072
# entry per `[rpc_endpoints]` alias, because the deploy goes to all of them.
73+
#
74+
# Both sections are checked against `LibRainDeploy.supportedNetworks()`, in both
75+
# directions, by `testSupportedNetworksAreFullyConfigured`. Adding a network is
76+
# an edit to all three or a red test, not a broadcast that discovers it.
7177
[etherscan]
7278
arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" }
7379
base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" }

src/abstract/RainDeploySuitesBase.sol

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ error UnknownDeploymentSuite(string requested, string validSuites);
2626
/// to declare. When the candidate was a single struct this was true by
2727
/// construction; a list has to say it.
2828
///
29-
/// Raised from `allSuites`, which is the only way anything reads the
30-
/// declaration — `suiteNames` and `suiteByName` both go through it — so there
31-
/// is no reader that answers from an empty one.
29+
/// Raised from `checkedCandidateSuites` — see there for why the guard sits at
30+
/// that one read rather than at each reader.
31+
///
32+
/// `releasedSuites` is read directly by the chain group and by the frozen
33+
/// record check, and is untouched by this: a repo with no release is an
34+
/// ordinary state, and it is the CANDIDATE that the source anchor needs.
3235
error NoDeployCandidates();
3336

3437
/// One deployable unit: a named snapshot of one contract.
@@ -139,9 +142,10 @@ abstract contract RainDeploySuitesBase {
139142
/// override this, and a repo inherits exactly one declaration. So the list
140143
/// is here rather than left to the consumer to assemble.
141144
///
142-
/// MUST NOT be empty, which `allSuites` enforces. A deploy repo always
143-
/// compiles a current source, so there is always something to anchor to —
144-
/// see `NoDeployCandidates` for why an empty list is worse than it looks.
145+
/// MUST NOT be empty, which `checkedCandidateSuites` enforces. A deploy
146+
/// repo always compiles a current source, so there is always something to
147+
/// anchor to — see `NoDeployCandidates` for why an empty list is worse
148+
/// than it looks.
145149
/// @return The candidates.
146150
function candidateSuites() internal pure virtual returns (DeployCandidate[] memory);
147151

src/abstract/RainDeployVerifySnapshot.sol

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,25 @@ abstract contract RainDeployVerifySnapshot is RainDeployVerifyBase {
260260
/// Every release in the frozen record MUST be declared, so that the set the
261261
/// chain group checks is every release this repo has ever cut rather than
262262
/// the ones somebody remembered to list.
263+
///
264+
/// An empty walk passes, and is meant to: that is the state of every deploy
265+
/// repo before its first release. What makes it safe is that the root is
266+
/// the one a snapshot is WRITTEN to — `LIB_FS_ROOT` is the only spelling of
267+
/// it in `LibRainDeploySnapshot` and `testRecordRootIsTheRootTheWriterWritesTo`
268+
/// pins it against `LibFs`'s. Under the writer's own root, finding nothing
269+
/// means there is nothing; under any other, the walk returns an empty list
270+
/// forever, this passes with no subject, and the one check standing between
271+
/// a release dropping out of everything and a green suite is inert.
272+
///
273+
/// Deliberately NOT also guarded by comparing the record's size against the
274+
/// declaration's. The two are emitted one-for-one by `writeReleasedSuitesLib`
275+
/// for a repo that generates its declaration from its record, but this is
276+
/// inherited by any repo that overrides `releasedSuites`, and a declaration
277+
/// with no record behind it is a state such a repo is legitimately in: a
278+
/// release deployed before it adopted this machinery has no frozen record
279+
/// and never will. A size check would red-line that permanently with no way
280+
/// to spell the exemption, while the release it names goes on being checked
281+
/// by everything anchored to a chain.
263282
function testEveryFrozenSnapshotIsReleased() external view {
264283
checkFrozenSnapshotsReleased(
265284
LibRainDeploySnapshot.frozenSnapshotPaths(vm, LibRainDeploySnapshot.LIB_FS_ROOT), releasedSuites()

src/abstract/RegistryDeploySuites.sol

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@ import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased.
2121
/// @title RegistryDeploySuites
2222
/// @notice Everything this repo deploys, declared ONCE.
2323
///
24-
/// Three contracts inherit this and nothing else declares a suite:
24+
/// These contracts inherit it, and nothing else declares a suite:
2525
///
2626
/// - `script/Deploy.sol` broadcasts from it
27+
/// - `script/Build.sol` generates the snapshots, the alias libs and the
28+
/// released-suites libs from it — the named candidates below are the
29+
/// templates it emits from, which is why it inherits the declaration rather
30+
/// than restating the key, the artifact path and the dependency list
2731
/// - `RegistryDeploySnapshotTest` checks its records against its creation code
2832
/// - `RegistryDeployChainTest` checks it against every chain
33+
/// - `GeneratedSnapshotShapeTest` checks the shape of the files that generation
34+
/// writes
2935
///
3036
/// So "the deploy script broadcasts one contract while the tests verify
3137
/// another" is not a thing that can be true here. Not because something checks
32-
/// for it — because there is only one list, and all three read it.
38+
/// for it — because there is only one list, and they all read it.
3339
///
3440
/// It lives in `src/` rather than `test/` for two reasons. `.soldeerignore`
3541
/// excludes `test/`, and a downstream `script/` has to import its own

src/lib/LibMigrationRegistry.sol

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,13 @@ library LibMigrationRegistry {
8585

8686
/// Reverts unless the pinned registry address holds the pinned code.
8787
///
88-
/// Both entry points check, and they check the same way, because both are
89-
/// worse than useless against unknown code: a read would branch a test on
90-
/// whatever that code returned, and a write would record a migration
88+
/// All three entry points check, and they check the same way, because each
89+
/// is worse than useless against unknown code: `applied` would branch a
90+
/// test on whatever timestamp that code returned, `head` would hand back a
91+
/// value that is not a head, and `applyMigration` would record a migration
9192
/// somewhere nothing will ever read it. The check is one function so the
92-
/// two cannot drift into checking different things.
93+
/// three cannot drift into checking different things, and an entry point
94+
/// added later has one place to call rather than a rule to remember.
9395
function checkCodeHash() internal view {
9496
bytes32 actualCodeHash = LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_ADDRESS.codehash;
9597
if (actualCodeHash != LibMigrationRegistryDeploy.MIGRATION_REGISTRY_DEPLOYED_CODEHASH) {
@@ -103,11 +105,13 @@ library LibMigrationRegistry {
103105
///
104106
/// Verifies the registry's code hash before reading, so a chain where the
105107
/// registry is absent, or where something else occupies its address, is a
106-
/// loud revert rather than a call into unknown code. That distinction is
107-
/// the whole point here: "no registry on this chain" and "this migration
108-
/// has not been applied" are different facts, and silently collapsing the
109-
/// first into the second would send a caller down its pre-migration branch
110-
/// on every chain the registry was never deployed to.
108+
/// NAMED revert rather than a call into unknown code. An absent registry
109+
/// reverts either way — solc reverts a high-level call whose returndata is
110+
/// too short to decode — but anonymously, saying nothing about which of the
111+
/// two it was. What the check actually forbids is the case that does NOT
112+
/// revert: code at the address that is not this registry, an EIP-7702
113+
/// delegation included, is free to answer zero to every migration and send
114+
/// every caller down its pre-migration branch.
111115
///
112116
/// The registry itself refuses the zero writer, and refuses the two ids a
113117
/// migration can never be, so those arrive as reverts from it rather than
@@ -129,10 +133,11 @@ library LibMigrationRegistry {
129133
/// if it has never applied one.
130134
///
131135
/// Verifies the registry's code hash first for the same reason `applied`
132-
/// does, and more sharply: a call into an empty account returns nothing,
133-
/// which decodes as zero, and zero is the one value a head can never hold —
134-
/// so an unverified read would hand back a head that is not a head at all,
135-
/// on exactly the chains where nothing has been deployed.
136+
/// does, and more sharply: occupying code is free to answer any head it
137+
/// likes, including the zero no head can ever hold, so an unverified read
138+
/// can hand back something that is not a head at all. An empty address is
139+
/// not that case — there is no returndata for a `bytes32` to decode from,
140+
/// so it reverts unguarded — and the check is what gives it a name.
136141
/// @param writer The namespace to read. Never the zero address.
137142
/// @return The head of `writer`'s namespace. Never zero.
138143
function head(address writer) internal view returns (bytes32) {
@@ -151,10 +156,12 @@ library LibMigrationRegistry {
151156
/// one account interleave into one chain.
152157
///
153158
/// Verifies the registry's code hash before writing, so a migration is
154-
/// never "applied" into an empty address or into unknown code. A record
155-
/// that went nowhere is worse than no record at all: the migration would
156-
/// have run, and every reader would go on asserting the pre-migration
157-
/// state.
159+
/// never "applied" into unknown code. A write into an EMPTY address fails
160+
/// unguarded — solc checks the callee exists when no return data is
161+
/// expected — so what this stops is the write that SUCCEEDS into something
162+
/// that is not the registry: a record that went nowhere is worse than no
163+
/// record at all, because the migration ran and every reader goes on
164+
/// asserting the pre-migration state.
158165
///
159166
/// The registry refuses the zero id, refuses a migration this caller has
160167
/// already applied, and refuses one applied onto anything but the

0 commit comments

Comments
 (0)