Skip to content

Assert [etherscan] entries resolve and bind each chain id to its network - #229

Merged
thedavidmeister merged 7 commits into
mainfrom
fix-192
Sep 16, 2026
Merged

thedavidmeister merged 7 commits into
mainfrom
fix-192

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #192.

What was wrong

testSupportedNetworksAreFullyConfigured asserted that a [etherscan] key
EXISTS for every supported network and nothing about what the entry contains.
An entry carrying only a key, under an alias foundry does not itself resolve
to a chain, satisfied every assertion in the group and is config that cannot
verify anything — foundry resolves the SECTION rather than the one entry the
network being verified needs, so it raises

At least one of `url` or `chain` must be present for Etherscan config with unknown alias `hyperevm`

for whichever network --verify was pointed at. foundry.toml, the test
NatSpec and the README each named that failure mode and each placed it outside
what was checked.

The hazard is live, not hypothetical. With chain = 999 dropped from the
hyperevm entry on main, forge verify-contract --chain 137 (polygon, a
different network entirely) dies on that error, while with the entry intact the
same command resolves and reaches the explorer. Both runs on this toolchain
(foundry 43923a4).

Stating chain on all nine entries then creates a second, quieter gap. Those
ids were read off cast chain-id once, by hand, and nothing re-reads them. A
wrong id resolves, reads as well-formed and passes every assertion in the
group, because every assertion in the group is about 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 gap swallows 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.

What changed

  • [etherscan] entries now state chain on all nine networks, not only on the
    four whose alias foundry cannot resolve. Chain ids were read off the repo's
    own [rpc_endpoints] with cast chain-id, and the four already stated (1,
    56, 999, 4663) came back matching, which is what validates the other five.
  • The config group now asserts that every [etherscan] entry carries at least
    one of chain or url — foundry's own condition, spelled strictly so that it
    needs to know nothing about foundry's alias table. Stating the chain an alias
    already resolves to resolves it to the same chain, so the strict form is
    monotonic and cannot red-line when foundry adds or renames an alias.
  • The group's assertions moved into RainDeployVerifySnapshotBase as
    checkNetworksConfigured(config, networks), leaving
    testSupportedNetworksAreFullyConfigured as the file read that binds them.
    This is the split the base already documents for
    checkFrozenSnapshotsReleased: a check that takes its subject as an argument
    can be handed one a test builds. Without it, deleting the new assertion's call
    site was a mutation nothing killed — the check would have existed while
    nothing proved it was wired.
  • testSupportedNetworkChainIdsAreBound in RainDeployVerifyChain forks
    every supported network that states a chain and compares the declared id
    against block.chainid, failing with NetworkChainIdMismatch(network, declared, reported). Both ids are in the error because which of the two is
    wrong — the declaration, or the alias the endpoint is bound to — is not
    something the check can know, and they are opposite fixes.
  • It is in the chain group rather than the config group because the subject is
    the endpoint, not the text. This is the contract that already forks, and
    keeping the comparison out of the snapshot half is what leaves that half
    bindable by a job with no RPC credentials.
  • An entry resolving through a url alone states no id and is skipped, since
    the config group requires only one of the two of it. Every entry being that
    way is NoDeclaredChainIds rather than a check that passes having forked
    nothing.
  • foundry.toml, the test NatSpec and the README passages updated: entry shape
    and the id binding are both inside the declared scope now, so the places that
    said the keys were the whole contract no longer say it, and the group table
    says what the chain group catches.

Two consumer-visible notes. A consumer repo whose [etherscan] section has an
entry with neither chain nor url goes red on bumping, which is the point;
the fix is to add chain = <id> to that entry. And RainDeployVerifyChain now
reads the binder's own foundry.toml, so the { access = "read", path = "./foundry.toml" } entry in fs_permissions that the snapshot half already
needed is needed by the chain half too — a repo binding RainDeployVerify has
it already.

Fork budget

Measured rather than assumed, because the new test is inherited rather than
declared once. testSupportedNetworkChainIdsAreBound creates nine forks per
concrete contract that inherits RainDeployVerifyChain, and four do
(RainDeployVerifyChainTest, RainDeployVerifyChainCandidateTest,
RainDeployVerifyChainEmptyTest, RegistryDeployVerifyTest): 36 fork
creations, plus 3 in the negative cases, for 39 more than base.

That is not visible in the wall clock: whole-suite runs against the same
endpoints came in at 66s, 66s and 72s on base and 64s, 65s and 112s here, which
is endpoint variance rather than a trend. It does not move the flake floor
either, and the floor is not zero. rpc.hyperliquid.xyz intermittently answers
invalid block height for a block it has just reported as latest, which
vm.createFork surfaces as a fork that cannot be instantiated — twice in three
base runs (testChainNotDeployedRevertsForALaterSuite,
testChainMatrixCoversEverySupportedNetwork) and once in four here
(testSuitesLiveOnEverySupportedNetwork), all three of them tests this PR does
not touch. One run here came back with seven fork failures while an immediate
re-run of the identical tree was 528/528. That is the public endpoints at this
fork volume, before and after; CI holds its own, and rainix / test is green
on this commit.

QA

  • Discriminating tests: testSupportedNetworkChainIdsAreBound is the binding
    itself; testChainIdMismatchReverts, testChainIdMatchPasses,
    testChainIdChecksEveryDeclaration,
    testChainIdIsReadFromTheForkedEndpoint, testChainIdNoDeclarationsReverts,
    testDeclaredChainIdsReadsTheConfigText,
    testDeclaredChainIdsSkipsEntriesWithNoChain,
    testDeclaredChainIdsSkipsNetworksWithNoEntry and
    testDeclaredChainIdsOfUrlOnlyEntriesIsRefused discriminate the pieces. For
    the earlier half, testConfigWithResolvableEtherscanEntriesPasses,
    testConfigWithUnresolvableLastEtherscanEntryReverts and
    testConfigWithUnresolvableMiddleEtherscanEntryReverts. Verified on base by
    breaking what they assert rather than by running tests that do not exist
    there: with src/ and test/ checked out at a741d90 and flare restated
    as chain = 15, the whole suite is 515 passed, 0 failed — a config that
    would submit Flare deployments to chain 15's explorer is invisible to every
    check on base. The same mutation on this commit is M08 below and fails with
    NetworkChainIdMismatch("flare", 15, 14). For the entry-shape half the
    equivalent is dropping chain = 999 from hyperevm, which on main leaves
    [PASS] testSupportedNetworksAreFullyConfigured() (gas: 74114) against an
    unmutated 74198 — the gas delta is the shorter config string and confirms
    the entry contents were never read.
  • Mutations applied: mutation-probe 0.1.0 (rainlanguage-skills), 9/9 KILLED,
    0 survived, 0 no-run, 0 harness errors, over two scopes, plus the earlier
    pass on RainDeployVerifySnapshotBase (9/9 KILLED) which still holds.
    • src/abstract/RainDeployVerifyChain.sol, scope --match-contract RainDeployVerifyChainTest (baseline green, 21 passed):
      • M01 if (declared != reported) -> if (false) ->
        testChainIdMismatchReverts
      • M02 select/compare loop i < declared.length -> i < 1 ->
        testChainIdChecksEveryDeclaration
      • M03 revert NoDeclaredChainIds() -> return ->
        testChainIdNoDeclarationsReverts
      • M04 vm.selectFork(forkIds[i]) deleted ->
        testChainIdIsReadFromTheForkedEndpoint
      • M05 count loop's vm.keyExistsToml(...) -> true ->
        testDeclaredChainIdsSkipsEntriesWithNoChain
      • M06 network: networks[i] -> network: "" ->
        testDeclaredChainIdsReadsTheConfigText
      • M07 next++ deleted -> testDeclaredChainIdsReadsTheConfigText
    • foundry.toml, scope --match-contract RainDeployVerifyChainEmptyTest --match-test testSupportedNetworkChainIdsAreBound (baseline green, 1
      passed):
      • M08 flare chain = 14 -> chain = 15 ->
        testSupportedNetworkChainIdsAreBound
      • M09 flare = "${FLARE_RPC_URL}" -> "${ARBITRUM_RPC_URL}" (the alias
        bound to another network) -> testSupportedNetworkChainIdsAreBound, with
        NetworkChainIdMismatch("flare", 14, 42161)
  • Oracle: foundry itself for the entry shape — the condition asserted is the
    text of foundry's own error, reproduced live on this toolchain by breaking the
    hyperevm entry and watching --verify fail on polygon. For the ids, cast chain-id against each [rpc_endpoints] URL, which is neither the config nor
    the check: arbitrum 42161, base 8453, base_sepolia 84532, bsc 56, ethereum 1,
    flare 14, hyperevm 999, polygon 137, robinhood 4663. All nine agree with what
    [etherscan] states, which is why the new test is green rather than red — and
    agreeing is what it is FOR, since nothing now lets them drift apart unnoticed.
  • Category check: The config check passes an [etherscan] entry that takes verification down for every network #192 asks for one thing — that an [etherscan] entry which
    cannot verify fail on the PR rather than after a broadcast — and names the
    closing assertion as "every [etherscan] entry carries at least one of
    chain or url". Covered exactly that, at exactly that strength. The chain
    id binding is added on top of it by direct ruling, because stating chain on
    nine entries is what made the ids worth re-checking and this is the PR that
    states them; it covers the declared id, the url-only entry that declares none,
    the config where nothing declares one, and the mirror case of an alias bound
    elsewhere. Nothing else in either group's contract changed — membership is
    still asserted in both directions, by membership, on the same raw text.

Suite: 515 passed / 0 failed at a741d90, 528 passed / 0 failed here (13 more:
nine new cases, and the inherited binding in each of the four contracts).
forge fmt --check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN

The config group asserted that a [etherscan] key exists per supported
network and nothing about the entry. An entry carrying only a key, under
an alias foundry does not itself resolve to a chain, satisfied every
assertion and is config that verifies nothing: foundry resolves the
section rather than the one entry the network needs, so it raises "At
least one of `url` or `chain` must be present for Etherscan config with
unknown alias" for whichever network --verify was pointed at.

Every entry now states `chain`, and the check requires at least one of
`chain` or `url` on each. Strict on every alias rather than only the ones
foundry cannot resolve, so it needs no knowledge of foundry's alias table.

The group's assertions move to RainDeployVerifySnapshotBase as
checkNetworksConfigured, which takes the config text, so a test can hand
it one; testSupportedNetworksAreFullyConfigured keeps the file read that
binds them. Without that, deleting the new assertion's call site was a
mutation nothing killed.

Closes #192

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 29 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 39531182-8b3f-4859-bee5-9a7684009455

📥 Commits

Reviewing files that changed from the base of the PR and between fcbeabc and 63fc5d9.

📒 Files selected for processing (7)
  • README.md
  • foundry.toml
  • src/abstract/RainDeployVerifyChain.sol
  • src/abstract/RainDeployVerifySnapshot.sol
  • src/abstract/RainDeployVerifySnapshotBase.sol
  • test/src/abstract/RainDeployVerifyChain.t.sol
  • test/src/abstract/RainDeployVerifySnapshotBase.t.sol

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`[etherscan]` now states a `chain` on all nine entries and the config group
asserts each entry carries `chain` or `url`, but nothing re-checks that a
stated id is the id its network reports. The nine were read off `cast
chain-id` once, by hand, and from then on a wrong one resolves, reads as
well-formed, passes every check that reads the text — and is what `--verify`
submits, so the deployment is verified against another chain's explorer after
the gas is spent.

`testSupportedNetworkChainIdsAreBound` forks every supported network that
states a `chain` and compares it against `block.chainid`. The same comparison
catches the mirror case, an `[rpc_endpoints]` alias bound to a different
network than it names, which is worse: every chain-anchored assertion ever
made through that alias was made somewhere nobody named.

In `RainDeployVerifyChain` because the subject is the endpoint. This is the
contract that forks, and keeping the comparison out of the snapshot half is
what leaves that half bindable by a job with no RPC credentials. It reads the
binder's own `foundry.toml`, so the chain half now needs the same
`fs_permissions` entry the snapshot half already does.

An entry resolving through a `url` alone states no id and is skipped, since
the config group requires only one of the two. Every entry being that way is
`NoDeclaredChainIds` rather than a check that passes having forked nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
@thedavidmeister thedavidmeister changed the title Assert [etherscan] entries can resolve, not just that the keys exist Assert [etherscan] entries resolve and bind each chain id to its network Sep 15, 2026
baku-ccron and others added 4 commits September 15, 2026 20:45
The `@notice` opens by stating what the whole group asserts, and the previous
commit added a second assertion family to this contract without extending it.
Left alone the sentence names only the deployment half while still reading as
the complete list, so the one thing a scope statement exists to answer — what
is in this group — is answered wrongly. The README table in that same commit
already carries both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Three conflicts, all where both sides appended to the same list, and all
resolved by keeping both: the `FrozenSnapshotAmbiguous` error main adds and the
`EtherscanEntryUnresolvable` error this branch adds, and the same pair in the
test's import list. `FrozenSnapshotAmbiguous` is placed with the other
`FrozenSnapshot*` errors it belongs to and `EtherscanEntryUnresolvable` after
them, since it is about a different file.

The memkv bump needed a resolution git could not see. This branch MOVED that
import into the new `RainDeployVerifySnapshotBase.sol`; main bumped it to 0.1.5
where it used to live. Taking both would leave the moved copy on 0.1.4 — which
no longer resolves — and a dead 0.1.5 import in `RainDeployVerifySnapshot.sol`,
which uses none of those symbols. So the import stays where this branch put it,
at main's version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
The sweep took the rationale as well as the restatement. What comes back is
every sentence that says something the code does not:

- `foundry.toml`: why `chain` is stated on EVERY entry rather than only on the
  aliases foundry cannot resolve itself, and that each one is compared against
  the id its alias reports. The second is this PR's whole subject.
- `NetworkChainIdMismatch`, `NoDeclaredChainIds`: what a revert means for a
  deriving repo — a deployment verified against another chain's explorer, or a
  check that passes having forked nothing.
- `DeclaredChainId`: the member docs, on a struct deriving repos read.
- `declaredChainIds`, `checkNetworkChainIds`, `checkEtherscanEntriesResolvable`,
  `checkNetworksConfigured`: why an entry with no `chain` is not a gap, why the
  config text is a parameter rather than a read, the fork ordering, why the
  strict form is monotonic under a toolchain bump, and why membership is
  asserted in both directions.
- `testSupportedNetworkChainIdsAreBound`, `testSupportedNetworksAreFullyConfigured`:
  why the comparison lives in the chain half, and why `fs_permissions` needs
  `foundry.toml` in the binder.
- the test docstrings that say what each case discriminates — the mismatch that
  is on the LAST entry, the id no network has, the empty declaration set, the
  entry that satisfies every membership assertion and still verifies nothing.

Left cut, because they restate a signature or a literal: the `@param`/`@return`
lines on the `external*` revert wrappers and on `fixtureNetworks`, and the
`exampleEtherscanConfig` block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

rainix / static / static is pre-existing, not this PR

Reproduced on a clean main. Tip feefc95, untouched working tree, in the shell CI pins:

$ nix develop github:rainlanguage/rainix/8657b83b68f41957ab85da91132c3f652c1f32c0#sol-shell -c forge lint -D warnings
Error: Compiler run failed:
Warning (2018): Function state mutability can be restricted to pure
   --> test/src/lib/LibRainDeploySnapshot.t.sol:667:5
Warning (2018): Function state mutability can be restricted to pure
   --> test/src/lib/LibRainDeploySnapshot.t.sol:679:5

These are solc diagnostics, not forge-lint rules. -D is a compiler flag, so
forge lint -D warnings denies solc's own warnings and aborts at compile before
reaching a single lint rule.

Why it reads as new. main's last run (35015487248, feefc95,
2026-09-15T19:45:08Z) is green and its static job has no forge lint step at
all — it ran soldeer install, slither ., forge fmt --check,
rainix-sol-single-contract. The step arrived upstream in
rainlanguage/rainix@55c8198e ("Gate every sol consumer on forge lint and the
pre-commit hook bundle") at 2026-09-15T20:59:35Z, 74 minutes after that run.
.github/workflows/rainix.yaml consumes rainix-sol.yaml@main, floating, so
every push from then on picks the gate up. main has not been pushed since, so
main is stale-green over red code.

This branch. merge-base feefc95f5f9bbf7bf14e179461bfc4aa7869f71e. LibRainDeploySnapshot.t.sol is not in this PR's diff at all; the two functions sit at 667 and 679 on both sides, byte-identical. Both functions came in with
a201ec8 (2026-09-14), which is on main.

Clearing the two warnings will not be enough. With both flipped to pure the
compile succeeds and forge lint reports four findings CI has never printed,
every one on code no restore branch touches:

  • missing-zero-check x2 — test/concrete/MockChainDependentOwner.sol:26, both constructor address params
  • boolean-cst — the trailing : false in the semver precedes ternary, test/src/lib/LibRainDeploySnapshot.t.sol:1601
  • block-timestamp — test/src/concrete/MigrationRegistryApplyMigration.t.sol:924

Error: aborting due to 4 linter warning(s). Behind forge lint, the same
upstream commit added a pre-commit run --all-files step that has never executed
on this repo because lint fails first, so what that step does here is unknown.

All six restore branches (#219, #226, #228, #229, #232, #234) fail identically on
code none of them touches. The fix belongs on main once, not six times.

@thedavidmeister
thedavidmeister merged commit 815c001 into main Sep 16, 2026
6 checks passed
thedavidmeister pushed a commit that referenced this pull request Sep 16, 2026
Six conflicts, five of them the same decision: #233 GENERATES the network
config from `LibRainDeploy.supportedNetworkConfigs()`, so a check that reads
`foundry.toml` back is the generator reading its own output. #229 landed on
main in the other direction — it strengthened those reads. Generation wins
everywhere the two meet.

- `README.md`, `foundry.toml`: the branch's text and the generated blocks. The
  `[etherscan]` block is emitted, so main's prose inside it and its
  hand-maintained comment cannot survive there; the rationale for stating
  `chain` on every entry lives in `LibRainDeployConfig` instead.
- `RainDeployVerifySnapshot.sol`: `testSupportedNetworksAreFullyConfigured` is
  gone. With both sides written from one list there is nothing to compare.
- `RainDeployVerifyChain.sol`, `RainDeployVerifyChain.t.sol`: the branch's
  roster-based `checkNetworkChainIds` is kept and main's `declaredChainIds`,
  `DeclaredChainId` and `NoDeclaredChainIds` are deleted, for the same reason.
  Main's prose about what a wrong `chain` costs is kept; so is its property
  that every entry is checked and not only the first, ported to the roster as
  `testChainIdChecksEveryEntry`.
- `BuildScript.t.sol`: both constant sets, which do not overlap.

Two things followed from those resolutions rather than being conflicts.

`checkNetworksConfigured` and `checkEtherscanEntriesResolvable` are deleted
from `RainDeployVerifySnapshotBase`, with `EtherscanEntryUnresolvable` and
their tests. #229 moved the comparison's body there from the test; deleting
the test leaves it dead, and it IS the comparison — keeping it would leave a
tested, consumer-callable assertion about a file this package now writes.

`testRunCallsEveryHookThatRegenerates` becomes `testRunCallsEveryGenerator`.
It required `run()`'s calls to be exactly the `internal virtual` hooks that
write, and `run()` now also calls `regenerateConfig`, which is deliberately
NOT a hook: the roster is this package's own, and a repo able to override the
emission would deploy to and verify fewer chains with nothing red. The set it
enumerates is now every `internal` function that can write, which holds the
same two claims over a strictly larger set and no longer turns on `virtual`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The config check passes an [etherscan] entry that takes verification down for every network

1 participant