Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9a0cc8d
feat(registry): address registry interface, reader lib and cross-netw…
thedavidmeister Aug 8, 2026
0958f42
test(registry): fork two networks in the cross-network gate test, not…
thedavidmeister Aug 8, 2026
bf5ac30
docs: Zoltu deploys with CREATE2 under a zero salt, not CREATE with a…
thedavidmeister Aug 8, 2026
605c1c2
feat(registry): mutable bindings, post-deploy verification, concrete …
thedavidmeister Aug 8, 2026
d58d007
test(deploy): repin testDeployZoltu's literal to this repo's compiler…
thedavidmeister Aug 8, 2026
10b14d5
feat(verify): one inherited deploy-pin verification, parameterized ov…
thedavidmeister Aug 13, 2026
4662b77
fix(verify): one contract per file, and keep slither on deployable co…
thedavidmeister Aug 13, 2026
226befc
feat(deploy): the on-chain deploy this repo had no way to run
thedavidmeister Aug 13, 2026
7461a93
fix(review): close the CodeRabbit findings that were real
thedavidmeister Aug 13, 2026
58a2cc8
feat(suites): one declaration, deployed and verified, as a registry
thedavidmeister Aug 13, 2026
7c60080
refactor(build): use the codegen library for codegen, don't reimpleme…
thedavidmeister Aug 13, 2026
589686c
feat(snapshot): candidate/frozen split, LibSnapshot moved in, .pointe…
thedavidmeister Aug 13, 2026
a4e5a7b
test(exemplars): the exemplar owns the shape, the compiler owns the v…
thedavidmeister Aug 13, 2026
721186f
test(snapshots): generate the test records, specify their shape from …
thedavidmeister Aug 13, 2026
e7b0384
refactor(snapshot): share the alias-lib emitter, and stop swallowing …
thedavidmeister Aug 13, 2026
f7e0fe5
test(snapshot): prove the release guards fire
thedavidmeister Aug 13, 2026
f04e559
refactor(verify): name the subject, and drive the tests from the real…
thedavidmeister Aug 13, 2026
befb78d
refactor(snapshot): bump rain-sol-codegen to 0.1.6 and use its filePr…
thedavidmeister Aug 13, 2026
a94040f
feat(verify): zero root for rollout, and chain checks only what released
thedavidmeister Aug 13, 2026
d8ebf90
fix(ci): slither filter follows the rename, SPDX fixture stops declar…
thedavidmeister Aug 13, 2026
f6c87dc
style: forge fmt, and the lint directive sits against the line it sup…
thedavidmeister Aug 13, 2026
0c5967d
fix(snapshot): a failed freeze is retryable, and the writer has one root
thedavidmeister Aug 13, 2026
0b7db36
fix(verify): the record is matched on what it declares, not on its text
thedavidmeister Aug 13, 2026
e37f499
test(chain): the candidate scope gets its own file
thedavidmeister Aug 13, 2026
95a41bf
feat(release): the record and the declaration of it come from one call
thedavidmeister Aug 14, 2026
dd2d93c
fix(release): a released lib describes one contract, not the record w…
thedavidmeister Aug 14, 2026
eb0b3f5
fix(verify): read the declaration the compiler reads, and restore the…
thedavidmeister Aug 14, 2026
696f2e5
style(test): wrap the writer call forge fmt wraps
thedavidmeister Aug 14, 2026
57618df
Review fixes: slither scope, address decode, and doc claims that were…
thedavidmeister Aug 14, 2026
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
1 change: 0 additions & 1 deletion .coderabbitai.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# SPDX-License-Identifier: LicenseRef-DCL-1.0
# SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd

reviews:
path_filters:
- "!audit/**"
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/manual-sol-artifacts.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Manual sol artifacts
# The on-chain deploy, run by hand. This repo carries a deployed concrete
# (`AddressRegistry`) whose address + codehash consumers pin, and
# `package-release.yaml` cuts a release for a deployment that ALREADY exists —
# rainix-tag-release verifies the live chains against freshly generated pins and
# never broadcasts. So the deploy has to happen first, and separately, which is
# this.
#
# Order is: dispatch this, confirm `AddressRegistryDeployChainTest` passes
# on every supported network, then push the `sol-v*` tag.
#
# Deliberately `workflow_dispatch` only. Broadcasting is key custody and real
# money; nothing about a merge or a tag should trigger it.
on:
workflow_dispatch:
jobs:
deploy:
uses: rainlanguage/rainix/.github/workflows/rainix-manual-sol-artifacts.yaml@main
with:
# Passed through as DEPLOYMENT_SUITE; script/Deploy.sol dispatches on it
# and reverts on anything else.
suite: address-registry
secrets: inherit
29 changes: 26 additions & 3 deletions .github/workflows/package-release.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
name: Package Release
# Deploy repo: a manual `sol-v*` tag is the sole release trigger. This repo now
# carries a deployed concrete (`AddressRegistry`) whose address + codehash
# consumers pin, which is exactly the shape rainix-tag-release exists for and
# exactly the shape rainix-autopublish's merge-driven, next-version lifecycle is
# wrong for: autopublish bumps [package].version on every merge while the frozen
# deploy tag only advances at deploy time.
#
# The tag names the version; rainix-tag-release runs `cutRelease()`, which
# freezes src/generated/<tag>/ AND regenerates the released-suites lib that
# declares it in one call, then verifies the live chains match the fresh pins,
# publishes rain-deploy to Soldeer, and commits both back to main. One call,
# because a frozen record no declaration names is a release every check silently
# stops asking about.
#
# The on-chain deploy is separate and manual, run BEFORE tagging; this never
# broadcasts. The chain verification inside rainix-tag-release is what fails if a
# newly declared release is not on chain, and that is the intended gate: a
# release is declared here only once it is a deployment that already happened.
#
# Switching lifecycles retracts nothing: every version already published stays
# published, and consumers pin exact versions, so this changes who cuts a
# release and nothing about how anyone consumes one.
on:
push:
branches:
- main
tags:
- sol-v*
jobs:
release:
uses: rainlanguage/rainix/.github/workflows/rainix-autopublish.yaml@main
uses: rainlanguage/rainix/.github/workflows/rainix-tag-release.yaml@main
with:
soldeer-package: rain-deploy
snapshot-generate-cmd: forge script ./script/Build.sol --sig "cutRelease()" && forge fmt
secrets: inherit
Comment thread
coderabbitai[bot] marked this conversation as resolved.
299 changes: 277 additions & 22 deletions CLAUDE.md

Large diffs are not rendered by default.

204 changes: 193 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ It answers:
- Have I deployed successfully to all expected networks?
- How do I track deployments over time and share addresses with other people?
- How do I ensure deployed code is bytecode-equivalent to local compilations?
- How does a deployment get a configured address — an owner, say — without
baking one into its creation code, where changing it would move every future
deployment?
- Is every version I have ever released still live, with the code I compiled, on
every network I support?

Approach:

Expand All @@ -25,6 +30,170 @@ Approach:
and against the chain after: silent failures fail loudly.
- Bytecode integrity checks (e.g. via the Rain Extrospection lib) supported
post-deploy.
- An address registry, read at run time rather than compiled into creation code,
and a post-deploy check that every target network's deployment took the
address it was supposed to.
- One inherited deploy-pin verification, parameterized over versions, rather
than assertions hand-enumerated per version and per chain in every deploy
repo.

## One declaration, deployed and verified

A repo declares its suites ONCE. A suite is a named snapshot: a key, the
creation code, the recorded address/code hash/runtime code, the artifact path
and the addresses that must already be on chain before it can be deployed.

```solidity
// 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);
}

// script/Deploy.sol
contract Deploy is MyDeploySuites, RainDeployBroadcast {}

// test/src/concrete/MyDeploySnapshot.t.sol
contract MyDeploySnapshotTest is MyDeploySuites, RainDeployVerifySnapshot {}

// test/src/concrete/MyDeployChain.t.sol
contract MyDeployChainTest is MyDeploySuites, RainDeployVerifyChain {}
```

The broadcast and the verification read the SAME array. "The deploy script ships
one contract while the tests verify another" is therefore not a statement that
can be true — not because something checks for it, but because there is nothing
for it to disagree with. A repo that wrote its suites out twice would have that
bug available to it; this one does not.

Suites are a **registry the abstract iterates**, not a chain of `else if`.
Adding a suite is adding an array entry. A mistyped `DEPLOYMENT_SUITE` reports
the valid keys built from that same array, so the error cannot fall behind the
suites it describes, and keys are checked unique because the key is what selects
what gets broadcast.

Every suite is individually selectable, including a frozen release — which is
how a snapshot from before a network existed reaches that network.

## Deploy verification

**The creation code is the only input.** The Zoltu factory is `CREATE2` over its
calldata under a zero salt, so the address is a pure function of the creation
code and identical on every network, and running that creation code once locally
yields the runtime code and its hash. Everything else a suite records is a
checked output.

Recorded rather than derived, deliberately. `LibRainDeploy` compares the
recorded address against the creation code **before it forks anything**, so a
stale pin fails instead of deploying to wherever the code happens to land.
Deriving the pins at broadcast time would make that comparison
derived-against-derived, and a guard that compares a value to itself is not a
guard.

Four groups, sorted by what each is anchored to and therefore by what each can
catch:

| Group | Anchored to | Catches | Cannot catch |
| -------- | ---------------------- | ------------------------------------- | -------------------------------- |
| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract |
| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain |
| Record | the frozen record | a release the declaration missed | what a declared suite records |
| Chain | the networks | never deployed, or not there any more | anything about the candidate |

The internal group's blind spot is not a gap to close there: every check in it
asks the recorded bytes to agree with each other, and the wrong contract's bytes
agree with each other perfectly. The source anchor is the only thing that
catches it, and it applies to the **candidate only** — a released tag is meant
to have diverged from current source, so anchoring one to source asserts
something false by design. That is a property of the assertion, and there is no
field on a released version with which to opt in or out.

The chain group carries the mirror image of that exemption: it applies to
**released versions only**. A release IS a deployment that happened, so "it is
live on every supported network" is either true of it or a defect. The candidate
is what the next release will be, ordinarily ahead of anything on chain, so
demanding it be live asserts something false by design in the other direction.
Neither exemption is a field a caller can set.

Scoping to releases puts the whole weight on `releasedSuites()` naming every
release, which is what the record group is for. A frozen tag the declaration
misses is not an entry that turns up missing somewhere — it is a release the
chain group is never handed, and a check with no subject cannot fail on it, so
that release drops out of everything while the suite stays green. The
declaration is generated from the append-only `src/generated/<tag>/` record and
checked back against it, matched by address, since matching by name would assert
only that a convention was followed.

**Chain-independent runtime code is a requirement, not a caveat.** One recorded
code hash per version can only be true if the runtime code is the same
everywhere. A constructor that reads `block.chainid` deploys different code per
chain: deploying through Zoltu buys address predictability, and such a
constructor spends it. So a per-chain difference fails hard, naming the chain
and both hashes, and there is deliberately no per-chain code hash to record.

## Address registry

`AddressRegistry` binds an opaque `bytes32` name to an address. An immutable
root authority binds a name, anyone reads a bound name, and reading an unbound
name reverts rather than answering with the zero address. There is no removal,
no upgrade and no authority besides root.

Bindings are **mutable**, because the addresses they name are. Rotating an
owning multisig is ordinary business and has to be expressible without moving
anybody's deterministic address — which a binding welded to one address forever
would make impossible, because the name is in the consumer's creation code, so a
new name means new creation code and a new address. That is the problem the
registry exists to remove, not a property worth keeping.

Mutability costs nothing already deployed. A consumer resolves a name **once**,
in its constructor, and stores the answer; it never reads the registry again. So
re-binding a name changes what the _next_ deployment resolves and nothing else,
which makes a rotation a deliberate migration rather than a silent change to
live contracts.

`LibAddressRegistry.resolve` is the read, verifying the registry's code hash
first, the same way `LibRainDeploy` verifies the Zoltu factory's. It resolves a
name to an address and stops there — what a consumer does with the address, and
when, is the consumer's business.

`LibRainDeploy.checkResolvedAddressesOnNetworks` is the **post-deploy**
verification: on every target network, the deployed contract must hold the
address the deployment expected. It runs after the deploy and before anything
depends on it, against state the deployment has already settled, so nothing it
reads can move underneath it. The same check run beforehand would be worth
nothing against a mutable source. A network where the deployment took something
else is a burned deterministic address, found while nothing points at it yet.

Only the consumer knows where it stored what it resolved, so the consumer
supplies the reads (`abi.encodeCall(IOwnable.owner, ())` and the like) and this
library supplies the fork loop and the comparison.

## 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.
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.
`[package].version` is the LAST released version, naming the current
`src/generated/<tag>/` snapshot, and only a release moves it. Every version
published under the previous merge-driven lifecycle stays published; consumers
pin exact versions and are unaffected.

## Install

Expand All @@ -34,6 +203,24 @@ Via [soldeer](https://soldeer.xyz):
forge soldeer install rain-deploy~<version>
```

**You also need `forge-std` 1.16.1**, remapped as `forge-std-1.16.1/`. The
published package deliberately ships only `src/` and `script/` — no
`remappings.txt`, no `soldeer.lock`, no `dependencies/` — so a consumer resolves
`forge-std` itself. The requirement is transitive rather than incidental: the
deployed contract imports nothing outside this package, but every abstract a
consumer inherits pulls forge-std in — `Script` via `RainDeployBroadcast`,
`Test` via `RainDeployVerifyBase`, and `Vm` via `LibRainDeploy` beneath both:

```toml
[dependencies]
forge-std = "1.16.1"
rain-deploy = "<version>"
```

The version has to match: the import paths are version-qualified, which is
deliberate — it is what stops a consumer's incompatible `forge-std` from
silently satisfying these imports.

## Develop

This repo uses [nix](https://nixos.org/download.html). The default shell is the
Expand All @@ -45,21 +232,16 @@ forge soldeer install # install deps declared in foundry.toml
forge test
```

Tasks:
The three CI jobs are rainix reusable workflows, not commands in the shell. What
each of them runs, which is what reproduces it locally:

- `rainix-sol-test` — `forge test`
- `rainix-sol-static` — slither
- `rainix-sol-test` — `forge test -vvv`
- `rainix-sol-legal` — `reuse lint`
- `rainix-sol-static` — `slither .`, `forge fmt --check`, then
`rainix-sol-single-contract`

Use the nix-pinned `forge` for all development.

## Publish

Tag `v<x.y.z>` on `main`. The
[`Publish to Soldeer`](.github/workflows/publish-soldeer.yaml) wrapper delegates
to rainix's reusable workflow, which derives the package name from the repo name
(`rain.deploy` → `rain-deploy`).

## License

DecentraLicense 1.0 (DCL-1.0) — full text in
Expand All @@ -71,7 +253,7 @@ This repo is [REUSE 3.2](https://reuse.software/spec-3.2/) compliant. Verify
locally:

```sh
nix develop -c rainix-sol-legal
nix develop -c reuse lint
```

## Contributions
Expand Down
52 changes: 51 additions & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
[package]
name = "rain-deploy"
version = "0.1.6"
# Deploy repo: this is the LAST released version, naming the current
# src/generated/<tag>/ snapshot, not a next-version slot. A normal PR does not
# bump it; only a `sol-v*` tag release moves it, in lockstep with the snapshot.
version = "0.1.5"

# SPDX-License-Identifier: LicenseRef-DCL-1.0
# SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
Expand All @@ -9,18 +12,65 @@ version = "0.1.6"
src = "src"
out = "out"
libs = ["dependencies"]

# This repo compiles a contract whose deterministic deploy address and code hash
# are pinned in LibAddressRegistryDeploy, and both are a pure function of the
# creation code, which is a function of these settings. They are pinned exactly
# rather than floated so the pins cannot move under a compiler or default-target
# change, and they match the settings the org's other deploy repos use.
solc = "0.8.25"
optimizer = true
optimizer_runs = 100000
evm_version = "cancun"
cbor_metadata = false
bytecode_hash = "none"

# GeneratedSnapshotShapeTest asserts the SHAPE of a generated deploy snapshot
# from the compiler's own AST, so the AST has to be in the artifacts that a
# plain `forge test` produces — not only under an explicit `--ast`.
ast = true

# Build reads the version from foundry.toml and writes the generated
# per-tag snapshots + the current-pin lib under src/. Nothing else in this repo
# touches the filesystem.
fs_permissions = [
{ access = "read", path = "./foundry.toml" },
{ access = "read-write", path = "./src" },
# LibRainDeploySnapshotTest builds a record tree of its own under
# test/generated to drive the frozen-record walk. NOT src/generated: the
# inherited record check reads that root from contracts forge runs in
# parallel, so a fixture release there would be one they have to fail on.
{ access = "read-write", path = "./test" },
# GeneratedSnapshotShapeTest reads the compiler's AST out of the artifact.
{ access = "read", path = "./out" },
]

[dependencies]
forge-std = "1.16.1"
rain-sol-codegen = "0.1.6"

[soldeer]
recursive_deps = false

# Every alias here is a network script/Deploy.sol broadcasts to and
# RainDeployVerifyChain forks. rainix's rpc-preflight action binds
# <NETWORK>_RPC_URL to a candidate that is reachable at the time of the run,
# rather than to one URL that may be dead, so these names are the contract with
# it.
[rpc_endpoints]
arbitrum = "${ARBITRUM_RPC_URL}"
base = "${BASE_RPC_URL}"
base_sepolia = "${BASE_SEPOLIA_RPC_URL}"
flare = "${FLARE_RPC_URL}"
polygon = "${POLYGON_RPC_URL}"

# `rainix-manual-sol-artifacts` passes `--verify` by default and exports exactly
# these variable names, so a deploy without this section broadcasts and then
# fails with no API key configured for the chain — after spending the gas. One
# entry per `[rpc_endpoints]` alias, because the deploy goes to all of them.
[etherscan]
arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}" }
base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}" }
base_sepolia = { key = "${CI_DEPLOY_BASE_SEPOLIA_ETHERSCAN_API_KEY}" }
flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}" }
polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}" }
1 change: 1 addition & 0 deletions remappings.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
forge-std-1.16.1/=dependencies/forge-std-1.16.1/
rain-sol-codegen-0.1.6/=dependencies/rain-sol-codegen-0.1.6/
Loading
Loading