fix(deploy): check the derived Zoltu address before skipping a network - #21
Conversation
deployToNetworks decided whether to deploy from `expectedAddress.code.length` alone, so an `expectedAddress` that disagrees with `creationCode` took the skip branch on every network and reported success without deploying anything. `zoltuAddress` derives the CREATE2 address the Zoltu factory produces for the given creation code, and `deployToNetworks` asserts it equals `expectedAddress` before forking any network. Co-Authored-By: Claude <noreply@anthropic.com>
A mutation moving the check inside the network loop survived the whole suite. An unconfigured RPC alias discriminates: the check reports the mismatch without any network being reachable. Co-Authored-By: Claude <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
Walkthrough
ChangesDeterministic address validation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant deployToNetworks
participant zoltuAddress
participant NetworkFork
participant ZoltuFactory
deployToNetworks->>zoltuAddress: derive address from creationCode
zoltuAddress-->>deployToNetworks: return deterministic address
deployToNetworks->>deployToNetworks: validate expectedAddress
deployToNetworks->>NetworkFork: fork network
NetworkFork->>ZoltuFactory: deploy creationCode
ZoltuFactory-->>NetworkFork: return deployed address
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
🤖 ai:vetter |
|
🤖 ai:vetter |
`LibRainDeploy.t.sol` pinned `MockDeployable`'s Zoltu address 22 times, its deployed code hash 7 times and `MockDeployableV2`'s Zoltu address twice, all as literals. Every one of those is the output of a formula the library now exposes, so each is a copy of a derived value that goes stale the moment the creation code changes. `mockDeployableAddress`, `mockDeployableCodeHash` and `mockDeployableV2Address` are now the single source for each, computed from `LibRainDeploy.zoltuAddress(type(Mock).creationCode)` and `keccak256(type(MockDeployable).runtimeCode)`. `testDeployZoltu` keeps its pinned literal: the live Zoltu factory on an Arbitrum fork is the oracle there, so taking the expected value from `zoltuAddress` would check the derivation against itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`MockDeployableV2` is a concrete contract, so its pragma is `=0.8.25` rather than `^0.8.25`, which floats to the newest solc available. `LibRainDeploy.t.sol` imports every other source file in the build, so the one exact pragma resolves the whole test compilation to 0.8.25 instead of 0.8.35. That changes the creation code of the mocks — solc 0.8.25 emits `5f80fd` (PUSH0 DUP1 REVERT) where 0.8.35 emits `5f5ffd` (PUSH0 PUSH0 REVERT), same length, different bytes — and so changes the address the Zoltu factory deploys them to. Every expected address and code hash in the suite is now derived from the creation code, so all of them follow; `testDeployZoltu`'s pinned literal is the one value that cannot, and is updated to what the live factory on an Arbitrum fork returns for the 0.8.25 creation code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-up fixes pushed to this branchHead is now 1. The derived constants are now derived — 30 literals removed, 1 keptThe test file pinned, as literals, three values that are all outputs of the CREATE2-over-creation-code formula this PR itself adds:
…against 4 calls to function mockDeployableAddress() internal pure returns (address) {
return LibRainDeploy.zoltuAddress(type(MockDeployable).creationCode);
}
function mockDeployableCodeHash() internal pure returns (bytes32) {
return keccak256(type(MockDeployable).runtimeCode);
}
function mockDeployableV2Address() internal pure returns (address) {
return LibRainDeploy.zoltuAddress(type(MockDeployableV2).creationCode);
}
The one surviving literal is in address deployed = this.externalDeployZoltu(type(MockDeployable).creationCode);
assertEq(deployed, 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854);Taking that expected value from
2.
|
| solc 0.8.35 | solc 0.8.25 | |
|---|---|---|
MockDeployable address |
0xC24016f209562fc151e5Ab7F88694ED5775feb36 |
0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 |
MockDeployable code hash |
0xc1a263a0…620c5483 |
0x6ea525f6…c78cc299 |
MockDeployableV2 address |
0x1efA03dD8f7D8e86Bbd2eEBe25f63052e95C002B |
0xDDB4cB0c3eaCE82Fbb15840CaeBDEc1A0Cdf98c3 |
This is why the derivation had to land first. With the constants derived, pinning the pragma carried all 30 derived occurrences across automatically and exactly one test failed — the pinned oracle:
[FAIL: assertion failed: 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854
!= 0xC24016f209562fc151e5Ab7F88694ED5775feb36] testDeployZoltu()
The left-hand value there is what the real Zoltu factory returned on the Arbitrum fork, not the derivation — so the on-chain oracle is what dictated the updated literal, and it stays an independent check rather than a restatement of zoltuAddress.
Doing it in the other order is how #23 went wrong: it pinned the pragmas and then hand-updated 18 derived literals to their new values, which is the same defect re-pinned at a different compiler.
Verification
Repo's own toolchain, matching CI (nix develop github:rainlanguage/rainix/53e96a7d0a97d7c7c75c3b2412521324776fdac6#sol-shell), after forge soldeer install:
forge test— 30 passed, 0 failed, 0 skipped. No fork test was skipped or stubbed: the Arbitrum and Base fork tests all ran against live RPCs, including the full Base archive binary search intestFindDeployBlockZoltuFactoryandtestIsStartBlockAtDeployBlock.slither .— 4 contracts, 100 detectors, 0 resultsforge fmt --check— cleanrainix-sol-single-contract— cleanreuse lint— compliant, 20/20 files
That the derivation reproduces the values it replaced, rather than merely being self-consistent, is pinned by the intermediate commit 1695862: derivation only, still solc 0.8.35, oracle literal still 0xC240…, green 30/30. Same addresses and same code hash as before, now computed instead of copied.
Deliberately left alone
MockDeployable.sol and MockReverter.sol still carry ^0.8.25. They are concrete too, but this PR does not otherwise touch them. Their effective version is already 0.8.25 via the constraint intersection described above, so behaviour is unaffected; making the pragmas say so is a separate sweep.
`zoltuAddress` is the only function this branch adds to `LibRainDeploy` that declared a named return. The three counterparts it adds to the test contract — `mockDeployableAddress`, `mockDeployableCodeHash` and `mockDeployableV2Address` — already declare unnamed returns with an explicit `return`, and unnamed returns are the prevailing form across rain Solidity. Declaring `returns (address)` with an explicit `return`, and the matching unnamed `@return` tag, makes the four functions the branch adds consistent with each other and with the rest of the org. The five pre-existing named returns in this file are deliberately left alone. Converting one means adding an explicit `return` on every path of a function whose paths have to be enumerated first, which is semantic risk for a style win in a deploy library. No behaviour change: the body is a single unconditional assignment, so assigning a named return and returning the same expression produce the same value. Every derived address and code hash, and the pinned oracle literal in `testDeployZoltu`, are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QA evidence for
|
| value | before | after |
|---|---|---|
MockDeployable derived address |
0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 |
0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 |
MockDeployable runtime code hash |
0x6ea525f6523fe148810254f04b9a74a379f59ca0f3a7fa83db90b691c78cc299 |
0x6ea525f6523fe148810254f04b9a74a379f59ca0f3a7fa83db90b691c78cc299 |
MockDeployableV2 derived address |
0xDDB4cB0c3eaCE82Fbb15840CaeBDEc1A0Cdf98c3 |
0xDDB4cB0c3eaCE82Fbb15840CaeBDEc1A0Cdf98c3 |
MockDeployableV2 runtime code hash |
0x62d58ae725f485b042d3fcac2842a3cd945a78e77b45b0f5d110cf6660c60311 |
0x62d58ae725f485b042d3fcac2842a3cd945a78e77b45b0f5d110cf6660c60311 |
All four byte-identical. Separately, git diff HEAD~1 -U0 contains no hex literal on any changed line at all — no address, no code hash, no test expectation is inside the diff.
The one surviving pinned literal, the live-fork oracle 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 in testDeployZoltu, is unchanged. Note it independently equals the cast create2 derivation above, computed outside the library — so the oracle and the derivation agree via a third path, not only via zoltuAddress checking itself.
No pragma was touched; the test build still resolves to solc 0.8.25 via MockDeployableV2's =0.8.25 pin, which is what those addresses are a function of.
Checks run locally (all in the repo's nix sol-shell)
| check | result |
|---|---|
forge test |
30 passed, 0 failed, 0 skipped |
forge fmt --check |
clean, exit 0 |
slither . |
4 contracts with 100 detectors, 0 result(s) found |
reuse lint |
REUSE 3.3 compliant, 20/20 files, 0 missing licenses |
| no-custom-natspec | no @custom: tags |
| no-ignored-tests | no vm.skip, no xtest |
| no-submodules | no .gitmodules |
| one contract per file | 1 per file across all 5 .sol files |
rainix-sol-single-contract is not on PATH in this repo's flake-pinned sol-shell (CI invokes it from rainix at RAINIX_SHA=53e96a7d), so its gate was verified by hand as the last row rather than by running the binary.
… version The org pragma convention is `=` for concrete contracts including concrete test mocks. `MockDeployableV2` already pins `=0.8.25`; `MockDeployable` and `MockReverter` floated `^0.8.25`. The float matters to this branch because `testDeployZoltu`'s pinned oracle literal is the address the live Zoltu factory returns for the creation code solc 0.8.25 emits for `MockDeployable`. With `^0.8.25`, `MockDeployable`'s own pragma admits newer compilers under which that literal is wrong; its effective version was only held at 0.8.25 by `MockDeployableV2`'s `=0.8.25` pin constraining the shared test build. Pinning the two siblings makes each mock's compiler pin direct, and the `testDeployZoltu` comment now cites `MockDeployable`'s own pin instead of the other file's side effect. The build already resolved 0.8.25, so nothing moves: creation code, runtime code hashes and derived addresses are byte-identical before and after, and the oracle literal is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QA evidence for
|
| value | before | after |
|---|---|---|
MockDeployable derived address |
0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 |
identical |
MockDeployable runtime code hash |
0x6ea525f6523fe148810254f04b9a74a379f59ca0f3a7fa83db90b691c78cc299 |
identical |
MockDeployable creation code hash |
0x2f579dc9bdd37f6d125494e0ffd9ccd0ee729ed40f356329e95ec8f86047cba8 |
identical |
MockReverter creation code hash |
0xf433f3015cb568cc8dca65e047a57bfdd17df704a7fa8eb1499fbecbc8228c07 |
identical |
MockDeployableV2 derived address |
0xDDB4cB0c3eaCE82Fbb15840CaeBDEc1A0Cdf98c3 |
identical |
MockDeployableV2 runtime code hash |
0x62d58ae725f485b042d3fcac2842a3cd945a78e77b45b0f5d110cf6660c60311 |
identical |
All six byte-identical (diff empty after stripping terminal control bytes from the captures). Separately, git diff HEAD~1 -U0 contains no hex literal on any changed line — the oracle literal 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 appears in the hunk only as unchanged context.
Tests: 30/30 before, 30/30 after, nothing skipped
Suite result: ok. 30 passed; 0 failed; 0 skipped; finished in 9.70s
Same 30 test names as the previous head. Fork tests ran live (Base archive binary search, live Arbitrum Zoltu factory) — none skipped.
Checks run locally (repo nix sol-shell)
| check | result |
|---|---|
forge test |
30 passed, 0 failed, 0 skipped |
forge fmt --check |
clean, exit 0 |
slither . |
4 contracts with 100 detectors, 0 result(s) found |
reuse lint |
REUSE 3.3 compliant, 20/20 files |
Resulting pragma state: =0.8.25 on all three concrete mocks; ^0.8.25 on the library. (LibRainDeploy.t.sol is itself a concrete test contract still on ^0.8.25 — observed but deliberately not touched, it is outside this change's scope.)
The org tree convention is that `test/src/**` mirrors `src/**` — `.t.sol`
files only, each at its subject's path — while `test/{lib,concrete,abstract}`
holds the suite's own support code placed by its own kind. The three mocks
are concrete support contracts, not tests of anything under `src/lib/`, so
they belong in `test/concrete/`, not inside the mirror tree in a `lib/`
directory. Not `test/src/concrete/`: this repo has no `src/concrete/`, so
that path would claim tests of a source tree that does not exist.
`LibRainDeploy.t.sol` stays at `test/src/lib/` — its subject is
`src/lib/LibRainDeploy.sol`, so that is its correct mirror home. Its three
mock imports follow the move, and the `contractPath` string literals passed
to `deployToNetworks`/`deployAndBroadcast` name the real new path, since
they feed `forge verify-contract` commands.
Pure moves: `foundry.toml` sets `cbor_metadata = false` and
`bytecode_hash = "none"`, so source paths never enter bytecode. Creation
code, runtime code hashes and derived addresses for all three mocks are
byte-identical before and after, and the oracle literal in
`testDeployZoltu` is untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
QA evidence for
|
| value | before = after |
|---|---|
MockDeployable creation code hash |
0x2f579dc9bdd37f6d125494e0ffd9ccd0ee729ed40f356329e95ec8f86047cba8 |
MockDeployableV2 creation code hash |
0x70bb1d6f105fa71822c613fdd529385edd76b8f9866eb6a04ff952a39d775c6a |
MockReverter creation code hash |
0xf433f3015cb568cc8dca65e047a57bfdd17df704a7fa8eb1499fbecbc8228c07 |
MockDeployable derived address |
0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 |
MockDeployable runtime code hash |
0x6ea525f6523fe148810254f04b9a74a379f59ca0f3a7fa83db90b691c78cc299 |
MockDeployableV2 derived address |
0xDDB4cB0c3eaCE82Fbb15840CaeBDEc1A0Cdf98c3 |
MockDeployableV2 runtime code hash |
0x62d58ae725f485b042d3fcac2842a3cd945a78e77b45b0f5d110cf6660c60311 |
diff of the before/after captures: byte-identical, all seven. Separately, git diff -M100% -U0 contains no hex literal on any changed line — the oracle literal 0x1fa1bBf9Cf73B1aCCc1a3D9de5896E81Cd567854 in testDeployZoltu is untouched, and the five pre-existing named returns in src/lib/LibRainDeploy.sol are verified still present.
Tests: 30/30, nothing skipped, from a clean build
forge clean first, so the suite compiled from the moved paths with no stale artifacts:
Suite result: ok. 30 passed; 0 failed; 0 skipped; finished in 11.36s
Same 30 test names as the previous head. Fork tests ran live (Base archive binary search, live Arbitrum Zoltu factory) — none skipped.
Checks run locally (repo nix sol-shell)
| check | result |
|---|---|
forge test (post-forge clean) |
30 passed, 0 failed, 0 skipped |
forge fmt --check |
clean, exit 0 |
slither . |
4 contracts with 100 detectors, 0 result(s) found |
reuse lint |
REUSE 3.3 compliant, 20/20 files (moved files kept their SPDX headers) |
|
Reviewed 900a879: ready — derived addresses guard the stale-expected-address case the issue asked for; magic numbers derived to one irreducible fork-attested oracle; all three concrete mocks exact-pinned so the oracle's compiler is direct; named return dropped; tree matches the convention (mirror .t.sol by subject, mocks in test/concrete by own kind); every step carried a nothing-moved proof and 30/30 live-fork suite. |
Refs #16
deployToNetworksdecided whether to deploy fromexpectedAddress.code.lengthalone, never checking that
expectedAddressis wherecreationCodeactuallylands. The Zoltu factory is CREATE2 over its calldata with a zero salt —
CALLDATACOPYthenCREATE2(callvalue, 0, calldatasize, 0)— so that addressis a pure function of the creation code and is computable in-library. New
zoltuAddressderives it, anddeployToNetworksasserts it equalsexpectedAddressbefore forking any network, so drifted constants fail loudlyinstead of matching the old contract on every network and exiting 0.
Three existing
deployToNetworksrevert tests passedhex""creation codealongside
address(0)— an input pair the library can never satisfy. They nowpass a coherent pair; their asserted revert selectors and arguments are
unchanged.
QA
testDeployToNetworksStaleExpectedAddressReverts,testDeployToNetworksStaleExpectedAddressRevertsBeforeForking,testZoltuAddressMatchesFactoryDeploy,testDeployToNetworksFactoryReportsOtherAddressReverts— each fails on base (verified by neutralising the new guard toif (false), which reproduces base exactly:testDeployToNetworksStaleExpectedAddressRevertswas the ONLY failure of 30, "next call did not revert as expected"; the pre-existingtestUnexpectedDeployedAddressRevertsstill passed, confirming it never discriminated this bug. The other three cannot compile on base —zoltuAddressdoes not exist there.)bytes1(0xff)→0xfe→ testZoltuAddressMatchesFactoryDeploy (0xe7EF…≠0xC240…);ZOLTU_FACTORY→address(0)→ same test (0xD196…≠0xC240…);bytes32(0)salt→bytes32(uint256(1))→ same test (0xa0B4…≠0xC240…);keccak256(creationCode)→creationCode→ same test (0x0D86…≠0xC240…); guard cond→false→ testDeployToNetworksStaleExpectedAddressReverts; revert args swapped → that test + testUnexpectedDeployedAddressReverts; guard hoisted above theNoNetworkscheck → testDeployToNetworksNoNetworksReverts; guard moved inside the fork loop → SURVIVED 29/29, gap closed by the new testDeployToNetworksStaleExpectedAddressRevertsBeforeForking, then killed; post-deploydeployedAddress != expectedAddress→false→ testDeployToNetworksFactoryReportsOtherAddressReverts (so that branch is live, not dead). All restored viagit checkout --, tree verified clean between probes.MockDeployable→0xC24016f209562fc151e5Ab7F88694ED5775feb36andMockDeployableV2→0x1efA03dD8f7D8e86Bbd2eEBe25f63052e95C002Bare both what an actualdeployZoltureturns on-chain; the expected revert argument is that literal, not a call tozoltuAddress. Third-party cross-check: for empty creation code the derivation yields0x5DC93B79FBDD6f26Ed9540597C78eD5893F9aC7A, independently equal to theZOLTU_EMPTY_CREATION_CODE_ADDRESSconstant PR test(deploy): close the coverage gaps a whole-repo mutation pass found #19 obtained from a real factory call.expectedAddressis stale relative tocreationCode, on the skip path as well as the deploy path, before any broadcast is simulated; B) confirm the skip branch bypassing dependency and Zoltu-factory checks is still intended. Covered A (checked before any fork, so both branches and no broadcast). Refs rather than Closes because B is a question about intent, not a defect — that behaviour is unchanged here, still documented in thedeployToNetworksNatSpec and pinned bytestDeployToNetworksSkipsAlreadyDeployedWithMissingDependency, and needs a human design ruling.Co-Authored-By: Claude noreply@anthropic.com
Summary by CodeRabbit