Skip to content
Open
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
# `foundry.toml` has to resolve — not just the one for the network under test.
#
# These are public endpoints and they rate-limit. CI holds its own.
#
# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`,
# between the markers and nowhere else.
# rain-deploy:generated:env:begin
ARBITRUM_RPC_URL=https://arb1.arbitrum.io/rpc
BASE_RPC_URL=https://mainnet.base.org
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
Expand All @@ -14,3 +18,4 @@ FLARE_RPC_URL=https://flare-api.flare.network/ext/C/rpc
HYPEREVM_RPC_URL=https://rpc.hyperliquid.xyz/evm
POLYGON_RPC_URL=https://polygon-bor-rpc.publicnode.com
ROBINHOOD_RPC_URL=https://rpc.mainnet.chain.robinhood.com
# rain-deploy:generated:env:end
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ out
.pre-commit-config.yaml
fixture-lib
target
.staged-config
1 change: 1 addition & 0 deletions .soldeerignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ CLAUDE.md
/slither.config.json
/soldeer.lock
/test
/.staged-config
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ relocated.
deployment. The deploy and the chain matrix create every fork before selecting
any, so such a failure takes the whole run before any network is checked
rather than stopping partway down the list.
- **Foundry REFUSES a cheatcode write to the project root's own
`foundry.toml`.** "access to `foundry.toml` is not allowed", from a guard on
the path — no `fs_permissions` grant and no spelling of the path gets past it,
and `writeFile`, `writeLine` and `copyFile` are all refused. Reads are
allowed. That is why `BuildScript.run()` stages the spliced config under
`.staged-config/` and `script/build.sh` installs it, and why regenerating by
hand is `forge script ./script/Build.sol` FOLLOWED BY `./script/build.sh`.
163 changes: 109 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,12 @@ for it to disagree with. A repo that wrote its suites out twice would have that
bug available to it; this one does not.

`BuildScript` carries both build entry points concrete. `run()` regenerates the
generated sources and freezes nothing; `cutRelease()` regenerates, freezes the
release as `src/generated/<tag>/`, then regenerates from the record that now
holds it. Neither is `virtual`, so the entry point CI runs on every push has no
way to cut a release.
generated sources and the network config, and freezes nothing; `cutRelease()`
regenerates, freezes the release as `src/generated/<tag>/`, then regenerates
from the record that now holds it. The config is written by `run()` alone: it is
not part of a release record, and `cutRelease()` is the one operation that
cannot be repeated. Neither is `virtual`, so the entry point CI runs on every
push has no way to cut a release.

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
Expand Down Expand Up @@ -129,16 +131,15 @@ Deriving the pins at broadcast time would make that comparison
derived-against-derived, and a guard that compares a value to itself is not a
guard.

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

| Group | Anchored to | Catches | Cannot catch |
| -------- | ---------------------- | ----------------------------------------- | -------------------------------- |
| Internal | the recorded set | an inconsistently generated set | a snapshot of the wrong contract |
| Source | `type(X).creationCode` | a snapshot of the wrong contract | anything about any chain |
| Record | the frozen record | a release the declaration missed | what a declared suite records |
| Chain | the networks | never deployed, gone, or a wrong chain id | anything about a candidate |
| Config | `foundry.toml` | a network it cannot fork or verify on | anything about a suite |
| 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 | a missing deployment, or an alias on another chain | 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
Expand Down Expand Up @@ -187,39 +188,42 @@ 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.

The config group is the only one whose subject is the CONSUMER's own
`foundry.toml` rather than its suites. `supportedNetworks()` is what the deploy
broadcasts to and what the chain group forks, `[rpc_endpoints]` is what makes an
alias forkable and `[etherscan]` is what makes `--verify` resolve, so the three
lists are one list and drift between them is a defect in either direction: a
supported network missing from a section broadcasts and then fails after the gas
is spent, and a section entry no supported network names is config nothing ever
reads. An `[etherscan]` entry carrying neither `chain` nor `url` under an alias
foundry cannot resolve is worse than missing — it takes verification down for
every entry in the section, not only its own — so membership is not the whole of
that half: every entry has to carry at least one of `chain` or `url` as well.
That is asked of every entry rather than only of the aliases foundry cannot
resolve, because which aliases those are is foundry's own table, and stating the
chain an alias already resolves to resolves it to the same chain.

It reads the raw file rather than forge's resolved config, because the values
are `${VAR}` interpolations that only exist in CI, and nothing it asserts is a
value — the keys and the entry shapes are both in the text. So it needs no RPC
and fails on the pull request that drifts rather than at dispatch time. Reading
the file at all is what a consumer has to allow: see [Install](#install).

Whether a stated `chain` IS the network its alias forks is the one thing about
that config the text cannot settle, so it belongs to the chain group instead:
`testSupportedNetworkChainIdsAreBound` forks every supported network that states
one and compares it against `block.chainid`. A wrong id resolves and satisfies
every check that reads the text, and `chain` is what `--verify` submits, so the
deployment is verified against another chain's explorer after the gas is spent.
The same comparison catches the mirror case — an `[rpc_endpoints]` alias bound
to a different network than it names — which is worse, because every
chain-anchored assertion ever made through that alias was made somewhere nobody
named. An entry resolving through a `url` alone states no id and is skipped;
every entry being that way is refused rather than passed as a check with no
subject.
**The network config is generated, not compared.**
`LibRainDeploy.supportedNetworkConfigs()` is the single statement of the set —
each network's name, chain id, explorer url and default endpoint — and
`BuildScript.run()` writes `foundry.toml`'s `[rpc_endpoints]` and `[etherscan]`
sections and `.env.example`'s endpoint variables from it. `Git is clean` is the
enforcement, the same mechanism already holding `src/generated/`: a tree whose
config has drifted from the roster it pins fails the job every push runs.

It reaches `foundry.toml` in two steps, because foundry refuses a filesystem
cheatcode write to the project root's own config whatever `fs_permissions` says.
`run()` stages the spliced files under `.staged-config/` and `script/build.sh`
installs them — see [Install](#install). Reads are allowed, which is what makes
the splice possible at all, and staging removes the hazard the direct write
would have carried: nothing under `forge test` can race a rewrite of the config
every other test reads, because nothing rewrites it.

That is what a config group used to be for, and comparing is the weaker half of
it. A comparison keeps both statements, so every assertion in it is one somebody
had to think of and the prose around them drifts silently; generation leaves one
statement and there is nothing left to compare. It also settles what a
comparison could only report: every generated `[etherscan]` entry states
`chain`, so an entry carrying neither `chain` nor `url` under an alias foundry
cannot resolve — which takes verification down for every entry in the section
and not only its own — is no longer a state a consumer can be in.

The roster is deliberately not overridable. A repo able to narrow it would
deploy to and verify fewer chains with nothing red, so a network arrives in a
consumer's config by a version bump and by nothing else.

The one thing generation cannot settle is whether a declared chain id is the one
the bound endpoint reports. That is a claim about the world rather than about
the text, and it is what `--verify` submits, so it sits in the chain group:
`testSupportedNetworkChainIdsAreBound` forks every supported network and
compares `block.chainid` against the roster. A wrong id there is config that
resolves, passes everything that reads the file, and verifies a deployment
against the wrong explorer.

## Address registry

Expand Down Expand Up @@ -661,19 +665,62 @@ The versions have to match: the import paths are version-qualified, which is
deliberate — it is what stops a consumer's incompatible copy from silently
satisfying these imports.

The config and chain groups both read the CONSUMING repo's `foundry.toml`, so
that repo has to allow it and has to have the sections to be read:
`BuildScript.run()` regenerates the CONSUMING repo's own `foundry.toml` and
`.env.example`, so that repo has to allow it:

```toml
fs_permissions = [{ access = "read", path = "./foundry.toml" }]
fs_permissions = [
{ access = "read", path = "./foundry.toml" },
{ access = "read", path = "./.env.example" },
{ access = "read-write", path = "./.staged-config" },
{ access = "read", path = "./script/build.sh" },
]
```

`[rpc_endpoints]` and `[etherscan]` then have to name exactly the networks in
`supportedNetworks()`, every `[etherscan]` entry has to carry at least one of
`chain` or `url`, and a `chain` it states has to be the chain id the endpoint
bound to that alias reports. Missing permission fails the check rather than
skipping it, which is the intended direction: a repo that cannot read its own
config is a repo whose config nothing has checked.
Read, not read-write, on the two generated files: foundry refuses every
filesystem cheatcode write to the project root's own `foundry.toml` — the guard
is on the path, so no `fs_permissions` grant and no spelling of the path gets
past it. `run()` therefore reads each file, splices its blocks, and writes the
result to `.staged-config/` under the same name.

What installs it is `script/build.sh`, the hook rainix's `rainix-copy-artifacts`
runs after `forge script ./script/Build.sol` and before the `git diff` that
fails a stale tree. Copy this repo's — it needs no forge, no nix and no `--ffi`,
which is the other way a script could reach a shell and is not taken: the
invocation that matters passes no `--ffi`, and granting it there would hand FFI
to every consumer's build rather than to one step.

```sh
# .gitignore
.staged-config
```

A repo with no `script/build.sh` is REFUSED rather than staged for, because
nothing else moves a staged file into place: generating for it would write the
roster where nothing reads it while the config went on saying whatever it said,
and the build would report success.

Both files then need the markers the generated blocks are spliced between, once
each and the begin before the end. A file carrying neither is refused, naming
the file and the block, rather than having the section appended to it — which is
a duplicate key at the next forge startup:

```toml
# rain-deploy:generated:rpc_endpoints:begin
# rain-deploy:generated:rpc_endpoints:end

# rain-deploy:generated:etherscan:begin
# rain-deploy:generated:etherscan:end
```

```sh
# .env.example
# rain-deploy:generated:env:begin
# rain-deploy:generated:env:end
```

Everything outside the markers is the consumer's, and the build neither reads
nor moves it.

## Develop

Expand All @@ -686,6 +733,14 @@ forge soldeer install # install deps declared in foundry.toml
forge test
```

Regenerating what this repo generates is two commands, and the second is not
optional — the first only stages the network config:

```sh
nix develop -c forge script ./script/Build.sol
./script/build.sh
```

Three of the CI jobs are rainix reusable workflows, not commands in the shell,
and the fourth is repo-local. What each of them runs, which is what reproduces
it locally:
Expand Down
62 changes: 32 additions & 30 deletions foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,23 @@ bytecode_hash = "none"
# 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.
# Build reads the version and the network sections from foundry.toml, stages the
# regenerated sections under .staged-config/ for script/build.sh to install, and
# writes the generated per-tag snapshots + the current-pin lib under src/.
# Nothing else in this repo touches the filesystem.
fs_permissions = [
# READ, not read-write. `BuildScript.regenerateConfig` reads these two and
# writes the spliced result to .staged-config/: foundry refuses a cheatcode
# write to this file whatever is granted here, so the write it would need is
# not a permission this list can hand out.
{ access = "read", path = "./foundry.toml" },
{ access = "read", path = "./.env.example" },
# Where the spliced files are staged, for script/build.sh to install over the
# two above. Gitignored; the hook removes it once it has installed them.
{ access = "read-write", path = "./.staged-config" },
# `writeStagedConfig` refuses a repo with no build hook, which is a repo where
# nothing would install what it staged.
{ access = "read", path = "./script/build.sh" },
{ access = "read-write", path = "./src" },
# LibRainDeploySnapshotTest builds a record tree of its own under
# test/generated-snapshot to drive the frozen-record walk. NOT src/generated:
Expand Down Expand Up @@ -74,6 +86,12 @@ recursive_deps = false
# <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.
#
# Generated from `LibRainDeploy.supportedNetworkConfigs()` by `script/Build.sol`,
# between the markers and nowhere else. Adding a network is an edit to the
# roster followed by a build, and `Git is clean` fails a tree where the two have
# come apart.
# rain-deploy:generated:rpc_endpoints:begin
[rpc_endpoints]
arbitrum = "${ARBITRUM_RPC_URL}"
base = "${BASE_RPC_URL}"
Expand All @@ -84,33 +102,23 @@ flare = "${FLARE_RPC_URL}"
hyperevm = "${HYPEREVM_RPC_URL}"
polygon = "${POLYGON_RPC_URL}"
robinhood = "${ROBINHOOD_RPC_URL}"
# rain-deploy:generated:rpc_endpoints:end

# `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.
#
# Both sections are checked against `LibRainDeploy.supportedNetworks()`, in both
# directions, by `testSupportedNetworksAreFullyConfigured`. Adding a network is
# an edit to all three or a red test, not a broadcast that discovers it.
#
# `chain` is stated on EVERY entry, and `testSupportedNetworksAreFullyConfigured`
# requires at least one of `chain` or `url` on each. An entry with neither, under
# an alias foundry does not itself resolve to a chain, is not a missing key, it
# is a config error — "At least one of `url` or `chain` must be present for
# Etherscan config with unknown alias" — raised while resolving the section, so
# it takes down verification for every network in it and not only its own.
#
# Stated on every entry rather than only on the aliases foundry cannot resolve,
# because which aliases those are is foundry's own table and that table moves
# under a toolchain bump. Stating the chain an alias already resolves to
# resolves it to the same chain, so neither these entries nor the check reading
# them has to know the table.
# `chain` is stated on every entry, from the roster's own chain id. An entry
# with neither `chain` nor `url` under an alias foundry does not itself resolve
# is not a missing key, it is a config error — "At least one of `url` or `chain`
# must be present for Etherscan config with unknown alias" — raised while
# resolving the section, so it takes down verification for every network in it
# and not only its own.
#
# Each stated `chain` is compared against the chain id its `[rpc_endpoints]`
# alias actually reports, by `testSupportedNetworkChainIdsAreBound`. That is the
# one claim here the text cannot settle on its own: a wrong id resolves, reads
# as well-formed, and is what `--verify` submits.
# Generated with `[rpc_endpoints]`, from the same roster, so the two cannot name
# different networks.
# rain-deploy:generated:etherscan:begin
[etherscan]
arbitrum = { key = "${CI_DEPLOY_ARBITRUM_ETHERSCAN_API_KEY}", chain = 42161 }
base = { key = "${CI_DEPLOY_BASE_ETHERSCAN_API_KEY}", chain = 8453 }
Expand All @@ -120,11 +128,5 @@ ethereum = { key = "${CI_DEPLOY_ETHEREUM_ETHERSCAN_API_KEY}", chain = 1 }
flare = { key = "${CI_DEPLOY_FLARE_ETHERSCAN_API_KEY}", chain = 14 }
hyperevm = { key = "${CI_DEPLOY_HYPEREVM_ETHERSCAN_API_KEY}", chain = 999 }
polygon = { key = "${CI_DEPLOY_POLYGON_ETHERSCAN_API_KEY}", chain = 137 }
# Robinhood Chain (4663) is not indexed by Etherscan V2. Its Blockscout
# explorer speaks the Etherscan API, so the entry points there; the key is
# whatever `CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY` carries (Blockscout ignores
# it). Blockscout sits behind a browser challenge that has rejected non-browser
# clients, so if `--verify` fails on this network after a broadcast, verify
# afterwards through Sourcify (which supports 4663 and which Blockscout
# imports): `forge verify-contract --verifier sourcify --chain 4663 ...`.
robinhood = { key = "${CI_DEPLOY_ROBINHOOD_ETHERSCAN_API_KEY}", chain = 4663, url = "https://robinhoodchain.blockscout.com/api" }
# rain-deploy:generated:etherscan:end
Loading
Loading