Skip to content

fix: read the candidate source anchor from the compiler, not the declaration - #242

Merged
thedavidmeister merged 3 commits into
mainfrom
fix-34-source-anchor
Sep 20, 2026
Merged

thedavidmeister merged 3 commits into
mainfrom
fix-34-source-anchor

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes rainlanguage/rain.factory.deploy#34.

What

RainDeploySuitesBase.checkCandidatesAnchoredToSource is documented as the ONLY
check that catches a snapshot of the wrong contract, and as the one guard the
irreversible broadcast path cannot be run past. It took BOTH of its operands
from the declaration: snapshot.creationCode for the record, and a
DeployCandidate.sourceCreationCode field for the source.

An ordinary field is one a consumer fills in. Pointing it at the same generated
constant as the record made the anchor compare a value with itself — satisfied
by construction for any candidate at all, including a consistent snapshot of an
entirely different contract, with a consumer's whole suite green and
RainDeployBroadcast.run() running the same neutered definition before it
broadcast. "No way to spell an exemption" is only true of an operand the
declaration cannot reach.

The source operand now comes from the COMPILER:

  • DeployCandidate.sourceCreationCode is removed. The struct keeps its
    single snapshot field.
  • checkCandidatesAnchoredToSource resolves the candidate's own
    snapshot.artifactPath — the <path>:<Name> artifact id that already
    existed, and that forge verify-contract already takes — through
    vm.getCode, and compares the record against that.
  • A consumer names the contract; what that contract compiles to is not something
    it gets a say in.

StdConstants.VM rather than inheriting CommonBase, because
RainDeploySuitesBase is inherited alongside Script and Test, which already
declare vm via CommonBase. A library constant is zero inheritance change for
every consumer: no C3 risk, no stdstore storage, no vm clash.

script/Build.sol's regenerateSnapshots now writes from
vm.getCode(candidate.snapshot.artifactPath) too — the same origin, read the
same way, that the anchor holds the written snapshot against. Generate-then-check
is one claim rather than two spellings of it.

artifactPath is now load-bearing for a candidate, and the README's
verification-groups table and prose are updated to say so.

Why

rainlanguage/rain.factory.deploy#34, found by adversarial mutation testing:
mutant M27 flipped a consumer's sourceCreationCode from
type(CloneFactory).creationCode to the recorded constant and survived the
consumer's whole 28-test suite. RainDeployBroadcast runs the same definition
before it broadcasts, so the guard was neutered on the irreversible path as well
as in CI, and CREATE2 at a zero salt puts whatever bytes it is handed at their
own permanent address on every chain the dispatch reached.

The issue raised three directions and answered none. This takes the first:
derive the source side rather than accept it as a field.

Red phase, and one honest caveat about it

The failing tests landed first, in 0c5d120, against unmodified source. Re-run on
that commit here before writing anything:

[FAIL: next call did not revert as expected]
  RainDeploySuitesBaseTest.testCandidateThatNamesAnotherContractIsRefused()

[FAIL: Error != expected error:
  UnknownDeploymentSuite("", "misanchored-candidate")
  != CandidateSourceMismatch("misanchored-candidate", 0x95b0a632…, 0x23106e67…)]
  RainDeployBroadcastTest.testRunRefusesToBroadcastACandidateThatNamesAnotherContract()

The second is the one that matters: run() got PAST the anchor and died later,
at suite selection. That is what an anchor the irreversible action does not
enforce looks like from inside a completed dispatch.

Discriminating controls PASS in the same run — testCandidatesPresentAnswers (a
declaration whose candidates really are their named contracts) and
testRunRefusesToBroadcastACandidateThatIsNotItsSource (a declaration that
contradicts itself, which an anchor trusting the declaration still catches).

The caveat. The fix removes the field, so the red fixture could not survive
unchanged. Pre-fix, MisanchoredDeploySuites spelled
sourceCreationCode: type(MockDeployableV2).creationCode — the #34 mutation
shape verbatim. Post-fix that field does not exist, and the fixture is simply a
candidate whose artifactPath names another contract. The two assertions are
identical in both phases
— same CandidateSourceMismatch selector, same suite
key, same two hashes — only the now-unspellable field is dropped. The red run
above used the PRE-fix fixture; it is not being presented as having used the
post-fix one.

One test is deleted, deliberately

RegistryDeploySuitesTest.testCandidatesAnchorAgainstCurrentSource — an AST
assertion that every candidate's sourceCreationCode was spelled
type(X).creationCode. Its subject field no longer exists, so the property it
guarded is now unspellable rather than unchecked. Its sole helper
expressionShape goes with it.

Its mirror, testCandidatesRecordTheGeneratedConstants, is untouched and
still runs
. That is the half a declaration CAN still get wrong, and it is now
the only half it can.

This is the only test removed anywhere in this PR.

Consumer migration — BREAKING

  1. DeployCandidate.sourceCreationCode is removed. Delete the field from
    every DeployCandidate({...}) literal, and usually the
    import {X} from "src/concrete/X.sol" that existed only to spell
    type(X).creationCode.

  2. DeploySuite.artifactPath becomes load-bearing for candidates. It must
    be a <path>:<Name> that vm.getCode resolves, uniquely, to the contract
    the snapshot is of. A consumer whose path was sloppy or stale now goes red —
    including inside RainDeployBroadcast.run(), BEFORE the broadcast. That
    failure is new and it is the right one: the field was previously read only by
    the forge verify-contract line LibRainDeploy prints AFTER a deploy, so a
    path left behind by a moved or renamed source file cost a deploy before it
    cost a test.

  3. checkCandidatesAnchoredToSource is internal view, not internal pure, and RainDeployVerifySnapshotBase.testSnapshotMatchesSource is
    external view. Any consumer wrapper marked pure must become view.

  4. No foundry.toml change is required. vm.getCode does not go through
    fs_permissions. Verified rather than assumed: this repo's
    { access = "read", path = "./out" } entry — which exists for
    GeneratedSnapshotShapeTest's AST read, not for this — was temporarily
    deleted and testSnapshotMatchesSource (×3),
    testCandidateThatNamesAnotherContractIsRefused,
    testRunRefusesToBroadcastACandidateThatNamesAnotherContract and
    testWrongContractSnapshotCaughtBySource all still PASS. A src/concrete/…
    path also resolves from inside a real forge script run (script/Build.sol),
    and a test/concrete/… path resolves through RainDeployBroadcast.run().

Residual hazard this does NOT close

The RECORD half can still be spelled type(X).creationCode, which puts both
operands back on the source side from the other direction. rain-deploy cannot
detect that from inside a consumer — this repo catches it for itself with
testCandidatesRecordTheGeneratedConstants, reading its own AST, which is not
something rain-deploy guarantees a consumer has. This is the mirror of #34 and
is worth its own issue.

QA

  • Discriminating tests: RainDeploySuitesBaseTest.testCandidateThatNamesAnotherContractIsRefused, RainDeployBroadcastTest.testRunRefusesToBroadcastACandidateThatNamesAnotherContract — each fails on base (re-run here at 0c5d120, the tests-only commit on unmodified 70e4426 source: "next call did not revert as expected", and UnknownDeploymentSuite("", "misanchored-candidate") != CandidateSourceMismatch(...) i.e. run() got past the anchor; both PASS on 60588d4). Controls testCandidatesPresentAnswers and testRunRefusesToBroadcastACandidateThatIsNotItsSource PASS in both phases, so the pair discriminates the anchor's source operand rather than any revert.
  • Mutations applied: two, both run here against the fixed source, both killed. (1) RainDeploySuitesBase.sol:345 bytes32 source = keccak256(StdConstants.VM.getCode(candidates[i].snapshot.artifactPath)); → bytes32 source = stored; — the test(fork): split the tests that need a chain from the ones that do not #34 mutant M27 expressed inside the abstract, i.e. the anchor comparing a value with itself → KILLED by testCandidateThatNamesAnotherContractIsRefused, testRunRefusesToBroadcastACandidateThatNamesAnotherContract and testWrongContractSnapshotCaughtBySource (3 failed, 4 passed; testSnapshotMatchesSource and testCandidatesPresentAnswers stayed green, as they must — they are the honest-declaration cases). (2) RainDeploySuitesBase.sol:343 i < candidates.length → i < 1 — a loop that never reaches a later candidate → KILLED by testWrongContractSnapshotCaughtBySource and testRunRefusesToBroadcastACandidateThatIsNotItsSource, whose fixtures put the broken candidate LAST. M27's original form — sourceCreationCode pointed at the recorded constant in a consumer's declaration — is no longer applicable at all: the field does not exist.
  • Oracle: the compiler, not the implementation. Both new tests assert the reported source hash equals keccak256(type(MockDeployable).creationCode) — the contract the candidate NAMES, computed by solc from the type expression, never read back through vm.getCode — so a vm.getCode that resolved to the wrong artifact, or to the declaration's own bytes, fails the assertion. For the script/Build.sol change the oracle is the committed tree: forge script script/Build.sol over a clean checkout leaves src/generated/ and src/lib/ byte-identical, so vm.getCode(artifactPath) demonstrably produces what sourceCreationCode produced.
  • Category check: issue test(fork): split the tests that need a chain from the ones that do not #34 lists three directions and answers none — (1) derive the source side rather than accept it as a field, (2) keep the field and assert differing origins at generation time, (3) accept it as ordinary miswiring and soften the NatSpec. Covered: (1). (2) is not taken — it keeps a field a consumer can still fill in on both sides, so the anchor stays satisfiable by construction on the broadcast path, which is the property the issue is about. (3) is subsumed: "no way to spell an exemption" is now true rather than softened, and the NatSpec is rewritten to say where the operand comes from. The issue's own residual half (the RECORD side spelled type(X).creationCode) is explicitly NOT closed here and is flagged above for its own issue.

Suite counts, measured

  • Before: forge test on 70e4426 (upstream main, unmodified): 490
    passed, 76 failed.
  • After: forge test on this branch: 491 passed, 76 failed.
  • The 76 failures are identical, line for line, in both runs — every one is
    vm.createSelectFork: environment variable *_RPC_URL not found, i.e. no RPC
    credentials in this environment. grep "^\[FAIL" | grep -v RPC_URL is empty
    on both, and the two sorted failure lists diff clean.
  • 491 = 490 + 2 − 1: two new tests, one deliberate deletion (above).
  • Oracle for the Build.sol change: forge script script/Build.sol over a
    clean tree is still a no-op — src/generated/ and src/lib/ are byte-identical
    after the run, so vm.getCode(artifactPath) produces exactly what
    sourceCreationCode did.
  • forge build, forge fmt --check and forge lint are all clean.
  • No foundry.toml, remappings.txt or dependency change.

🤖 Generated with Claude Code

baku-ccron and others added 2 commits September 20, 2026 11:52
Two tests, both failing against unmodified source, for
rainlanguage/rain.factory.deploy#34.

`MisanchoredDeploySuites` records a consistent snapshot of
`MockDeployableV2` under an `artifactPath` that names `MockDeployable`,
and wires `sourceCreationCode` at the same recorded bytes — the exact
mutation shape the issue found surviving a consumer's whole suite. The
declaration is internally silent: every check internal to a snapshot
passes on it.

Today `checkCandidatesAnchoredToSource` compares that value with itself,
so:

  [FAIL: next call did not revert as expected]
    testCandidateThatNamesAnotherContractIsRefused()

  [FAIL: Error != expected error:
    UnknownDeploymentSuite("", "misanchored-candidate")
    != CandidateSourceMismatch("misanchored-candidate", ...)]
    testRunRefusesToBroadcastACandidateThatNamesAnotherContract()

The second is the one that matters: `run()` got PAST the anchor and
failed later, at suite selection, which is what an anchor that does not
run on the irreversible path looks like from inside a completed dispatch.

The discriminating controls pass in the same run —
`testCandidatesPresentAnswers` (a declaration whose candidates really
are their named contracts) and
`testRunRefusesToBroadcastACandidateThatIsNotItsSource` (a declaration
that contradicts itself).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aration

Closes rainlanguage/rain.factory.deploy#34.

`checkCandidatesAnchoredToSource` is documented as the ONLY check that
catches a snapshot of the wrong contract, and as the one guard the
irreversible broadcast path cannot be run past. It took both of its
operands from the declaration: `snapshot.creationCode` for the record and
a `DeployCandidate.sourceCreationCode` field for the source. An ordinary
field is one a consumer fills in, so pointing it at the same generated
constant as the record made the anchor compare a value with itself —
satisfied by construction for any candidate at all, including a snapshot
of an entirely different contract, with a consumer's whole suite green
and `RainDeployBroadcast.run()` running the same neutered definition
before it broadcast.

The source operand now comes from the COMPILER. `DeployCandidate` loses
`sourceCreationCode`, and the anchor resolves the candidate's own
`snapshot.artifactPath` — the `<path>:<Name>` artifact id that already
existed, and that `forge verify-contract` already takes — through
`vm.getCode`. A consumer names the contract; what that contract compiles
to is not something it gets a say in, so there is no longer an operand
with which to spell the exemption.

`StdConstants.VM` rather than inheriting `CommonBase`, because
`RainDeploySuitesBase` is inherited alongside `Script` and `Test`, which
already declare `vm`. A library constant is zero inheritance change for
every consumer. Reading the compiler at all makes the check `internal
view` rather than `internal pure`, and `testSnapshotMatchesSource`
`external view` with it.

`script/Build.sol` writes its snapshots from
`vm.getCode(candidate.snapshot.artifactPath)` as well, so generating and
then checking is one claim rather than two spellings of it. Verified:
`forge script script/Build.sol` over a clean tree is still a no-op.

`artifactPath` becomes load-bearing for a candidate. A path that resolves
to no artifact, or to more than one, now fails inside the cheatcode at
the anchor — which for a candidate is BEFORE the broadcast. Previously
that field was read only by the verification command `LibRainDeploy`
prints after a deploy, so a path left behind by a moved or renamed source
file cost a deploy before it cost a test.

ONE test is deleted, deliberately:
`RegistryDeploySuitesTest.testCandidatesAnchorAgainstCurrentSource`, an
AST assertion that every candidate's `sourceCreationCode` was spelled
`type(X).creationCode`. Its subject field no longer exists, so the
property is unspellable rather than unchecked. Its sole helper
`expressionShape` goes with it. The mirror assertion
`testCandidatesRecordTheGeneratedConstants` — the half a declaration CAN
still get wrong — is untouched and still runs.

Suite: 490 passed before, 491 after (+2 new, -1 deleted), with the same
76 RPC-env failures in both, identical line for line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 56 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: 27fc5964-1429-42a5-887f-9ab0154153b6

📥 Commits

Reviewing files that changed from the base of the PR and between 70e4426 and e59ff13.

📒 Files selected for processing (28)
  • README.md
  • script/Build.sol
  • src/abstract/RainDeploySuitesBase.sol
  • src/abstract/RainDeployVerifySnapshotBase.sol
  • src/abstract/RegistryDeploySuites.sol
  • test/abstract/ExampleDeploySuites.sol
  • test/abstract/ExternalDeploySuites.sol
  • test/abstract/MisanchoredDeploySuites.sol
  • test/abstract/SourceMismatchDeploySuites.sol
  • test/concrete/CollidingCandidateDeploySuites.sol
  • test/concrete/DuplicateDeploySuites.sol
  • test/concrete/EmptyKeyDeploySuites.sol
  • test/concrete/MisanchoredDeploy.sol
  • test/concrete/MissingDependencyDeploy.sol
  • test/concrete/MultiSuiteDeploy.sol
  • test/concrete/SameLengthKeyDeploySuites.sol
  • test/concrete/SeparatorKeyDeploySuites.sol
  • test/concrete/ShortestKeyDeploySuites.sol
  • test/concrete/StaleCodeHashDeploy.sol
  • test/concrete/StalePinDeploy.sol
  • test/src/abstract/RainDeployBroadcast.t.sol
  • test/src/abstract/RainDeploySuitesBase.t.sol
  • test/src/abstract/RainDeployVerifyChainCandidate.t.sol
  • test/src/abstract/RainDeployVerifyChainEmpty.t.sol
  • test/src/abstract/RainDeployVerifySnapshotBase.t.sol
  • test/src/abstract/RainDeployVerifySnapshotBaseCandidate.t.sol
  • test/src/abstract/RegistryDeploySuites.t.sol
  • test/src/lib/GeneratedSnapshotShape.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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister
thedavidmeister merged commit b19a415 into main Sep 20, 2026
6 checks passed
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 candidate source anchor can be made self-comparing, and the suite stays green

1 participant