Skip to content

feat: open-salt deterministic clone variant (ICloneableFactoryV4) - #8

Open
thedavidmeister wants to merge 3 commits into
2026-07-25-rolling-candidate-modelfrom
factory/open-salt-clone-deploy
Open

feat: open-salt deterministic clone variant (ICloneableFactoryV4)#8
thedavidmeister wants to merge 3 commits into
2026-07-25-rolling-candidate-modelfrom
factory/open-salt-clone-deploy

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

The deploy half of rainlanguage/rain.factory#51
(which closes rainlanguage/rain.factory#50). That PR was written against the
pre-split rain.factory tree; rainlanguage/rain.factory#47 then slimmed that
repo to interfaces only, so five of its eight files targeted paths that no
longer exist there. They land here.

Redesigned in place, 2026-08-13, tracking #51's own in-place redesign at its
head b6e34ad.
What this branch carried before — a CREATE2 salt equal to the caller-supplied
salt verbatim, with data outside the derivation — no longer implements the
interface it claims to. Everything below describes what is on the branch now;
the old shape and what it cost are under "What changed and why".

The two entry points

CloneFactory gains a second deterministic entry point. cloneDeterministic
and predictDeterministicAddress are untouched — their msg.sender
namespacing is a guarantee other consumers rely on — so this is purely additive.

function cloneDeterministicOpenSalt(address implementation, bytes calldata data, bytes32 salt)
    external returns (address);
function predictDeterministicAddressOpenSalt(address implementation, bytes calldata data, bytes32 salt)
    external view returns (address);

The effective CREATE2 salt is the derivation ICloneableFactoryV4 fixes:

keccak256(abi.encode(ICLONEABLE_FACTORY_V4_OPEN_SALT_DOMAIN, salt, keccak256(data)))

so the clone address is a pure function of (factory, implementation, salt, data). The difference between the two derivations is not "sender or no sender",
it is what the address commits to: cloneDeterministic commits to WHO
deployed and not to WHAT; cloneDeterministicOpenSalt commits to WHAT and not
to WHO. predictDeterministicAddressOpenSalt therefore takes data — V3's rule
is that predict takes exactly the inputs of the derivation, and data is now one
of them.

_requireImplementationCode and _initializeClone are reused as-is, so
clone-and-initialize stays atomic and the open variant's failure modes are
identical to the existing one. NewClone is emitted with the caller-supplied
salt, not the effective one.

The interface, and the NatSpec that states the residual tx.origin MUST NOT on
implementations, live in rain.factory and are not duplicated here.

The domain separator, and why this repo is where it has to hold

ICloneableFactoryV4 states the disjointness of the two derivations as a MUST
NOT on the factory
: no other entry point may CREATE2 at an effective salt in
the open-salt image with caller-supplied data. The factory inherits exactly
such an entry point — cloneDeterministic takes arbitrary data and its
effective salt is keccak256(abi.encode(deployer, salt)). A rule stated on an
interface is held or broken by the concrete, so it is held here or nowhere.

CloneFactory holds it structurally: _effectiveSalt hashes 64 bytes led by a
left-padded address, _effectiveOpenSalt hashes 96 bytes led by the domain
constant. Without the domain word both preimages would be 64 bytes led by a word
the caller chooses, and abi.encode left-pads an address into exactly the word a
bytes32 salt already is — so any account A would reach every open-salt
address whose salt equals bytes32(uint256(uint160(A))) by calling
cloneDeterministic(implementation, evilData, keccak256(data)). A choice of
salt, not a preimage search.

testCloneDeterministicOpenSaltDisjointFromNamespacedAtLeftPaddedAddressSalt is
the test that fails if that stops holding. It does not assert an absence: it
builds the squat, asserts against the factory's own namespaced prediction
that an untagged derivation lands exactly on the honest party's address, then
asserts the real one does not, then deploys both for real and checks the honest
address is still free and still initializes to the honest data. Removing the
domain word from _effectiveOpenSalt fails it (mutant 1 below).

One equation is the whole of the reachable overlap —
keccak256(abi.encode(deployer, nsSalt)) == keccak256(abi.encode(openSalt, keccak256(data)))
— so closing it closes the mirror framing too (a victim whose namespaced salt is
keccak256(P) for reproducible P, reachable untagged by an open call passing
P as data). Same two unknowns from the other side, same word closes it; that
is stated on the test rather than duplicated as a second one.

Blocked-by, and why CI is red

blocked-by rainlanguage/rain.factory#51.

ICloneableFactoryV4 does not exist in any published rain-factory revision.
0.1.6 is the latest on the registry (published 2026-08-13, interfaces-only, and
predating the V4 file). So this branch pins rain-factory 0.1.7 and moves
every versioned import prefix with it (rain-factory-0.1.7/src/interface/…).
0.1.7 is published by autopublish when #51 merges.

Until then forge soldeer install cannot resolve this branch. Every rainix
job runs that install as its first real step, so all three checks are red for
the same single reason
legal included, which is easy to mispredict since
reuse lint itself has nothing to do with Soldeer:

Run: nix develop …#sol-shell -c forge soldeer install
Error: Failed to run soldeer: error during remappings operation:
       dependency not found: rain-factory~0.1.7

Nothing in this repo's own source has been evaluated by CI yet, and that red is
not to be chased
. The alternative — vendoring the interface here, or aliasing
rain-factory-0.1.7/ at 0.1.6 bytes — would make CI green by making the pin a
lie. soldeer.lock still carries the 0.1.5 entry for the same reason: there is
no 0.1.7 revision to lock, and the first successful install rewrites it. Merge
order is #51 first, then re-run CI here.

CodeRabbit did not review this PR: Review skipped: reviews are disabled for this base branch, because the base is #5's branch rather than main. There are
zero review threads, resolved or otherwise. Retarget to main once #5 lands and
it will review then.

And after 0.1.7 publishes, five tests are still red

The five LibCloneFactoryDeployProdTest fork tests assert the pinned address has
code on Arbitrum, Base, Base Sepolia, Flare and Polygon. Under the
rolling-candidate model this branch is stacked on, that pin tracks HEAD, and
this PR changes CloneFactory's bytecode, which is not deployed anywhere. They
will fail CloneFactory not deployed until a deploy is dispatched. That is the
coupling #5
documents under "Behaviour change to be aware of" and that CLAUDE.md already
states; there is no code fix for it on the branch. I have deliberately not
dispatched Manual sol artifacts.

Stacked on #5 — the 0_1_6 snapshot dir is NOT ported

base is 2026-07-25-rolling-candidate-model, the branch of
#5, because #5 has
not merged yet. Merge #5 first; this must not be squash-merged. If #5 lands
by another route, retarget this to main.

#51 originally hand-authored src/generated/0_1_6/CloneFactory.pointers.sol.
That artefact is wrong twice over and is not carried across:

  1. The number is taken and means something else. rain-factory 0.1.6 has
    published from the library repo, as interfaces-only. A 0_1_6 dir here would
    name a version that contains no CloneFactory at all.
  2. Hand-authored numbered dirs are not the model any more. Migrate to the rolling-candidate snapshot model #5 replaces
    frozen-numbered-dirs-at-author-time with a rolling src/generated/candidate/
    regenerated from source, frozen into a numbered dir only at tag time by
    script/cut-release.sh.

So src/generated/candidate/CloneFactory.pointers.sol is regenerated by
forge script ./script/BuildPointers.sol, and src/lib/LibCloneFactoryDeploy.sol
needs no edit — it already aliases candidate/, so the pin follows the source.
The frozen 0_1_3, 0_1_4 and 0_1_5 dirs are untouched, so the append-only
gate has nothing to flag (is_tag() requires three _-separated all-numeric
parts, and "candidate".split('_') has one; against main the file does not
even register as a modification, because candidate/ is itself added by #5).

The pin moved, because the bytecode moved. The regenerated candidate is now

address codehash
base branch / main (V3 only) 0x7053c1D000192f1FeC1fbe20350139F3300dBBaD 0x3ec02e2f…36379d00
this PR, verbatim-salt (superseded) 0x19272bCcFcb032eaC545E74ADFa168fDeD3e8d83 0x1a160099…187b4012
this PR, current 0xe03b178055685EAC2Fd8F012B444E379dE70576a 0xcdb039a288346702df9136f924d5b239d6f5fa01fef3273d76df928738c01cbd

.gas-snapshot is regenerated, and re-running BuildPointers on the committed
tree produces no diff.

Reproducing this locally before 0.1.7 exists

forge cannot fetch rain-factory 0.1.7 yet. To build this branch today, put
the unpublished package under the gitignored dependencies/ dir:

  1. Fetch the published 0.1.6 zip and extract it to
    dependencies/rain-factory-0.1.7/.
  2. Copy src/interface/ICloneableFactoryV4.sol from #51 head b6e34ad into its
    src/interface/.
  3. Point remappings.txt at it:
    rain-factory-0.1.7/=dependencies/rain-factory-0.1.7/.

Its src/interface/ is then byte-identical to #51's head — verified here with
diff -r against a clone checked out at b6e34ad, which reports no difference —
so the bytecode produced is what CI will produce once 0.1.7 publishes. Nothing
from that dir is committed.

Files

file
src/concrete/CloneFactory.sol _effectiveOpenSalt, the two entry points, is ICloneableFactoryV4, and the disjointness note
src/generated/candidate/CloneFactory.pointers.sol regenerated, not hand-written
test/src/concrete/CloneFactoryCloneDeterministicOpenSalt.t.sol new, 13 fuzz tests
test/src/lib/LibCloneFactoryDeployCandidate.t.sol + the entry-point dispatch test
foundry.toml rain-factory 0.1.5 → 0.1.7
test/src/concrete/{CloneFactoryCloneDeterministic.t.sol,TestCloneable.sol,TestCloneableFailure.sol} versioned import prefix only
README.md, CLAUDE.md the second entry point, its derivation, and the disjointness rule
.gas-snapshot regenerated

QA

  • Category check: Solidity contract change — additive external functions on a
    deployed-and-pinned concrete, plus a Soldeer major-consumer pin bump.
    Categories that apply: address-derivation correctness, cross-derivation
    image disjointness
    (new, and the substance of the redesign), atomicity of
    clone+initialize, non-regression of the existing sender-namespacing guarantee,
    deploy-pin/snapshot consistency under the rolling-candidate model, and
    versioned-import consistency across the pin bump (no unversioned remapping, no
    alias pointing a versioned path at different-version bytes — the local 0.1.7
    dir above is gitignored and is a reproduction aid, not a committed alias). Not
    applicable: storage layout (the factory is stateless), upgrade/migration
    (clones are immutable EIP1167 proxies), access control (the factory is
    permissionless by design, and that is the subject of the change rather than an
    omission).

  • Oracle: Open-salt deterministic clone variant: keep the deployer out of the address derivation rain.factory#50 and #51's interface read at b6e34ad,
    plus EIP-1167 and CREATE2 — not the implementation. Predicted addresses are
    asserted against OpenZeppelin's own Clones.predictDeterministicAddress under
    an independently constructed salt (the domain-tagged hash for the open
    variant, keccak256(abi.encode(deployer, salt)) for the namespaced one), so
    the tests do not restate CloneFactory's arithmetic back to itself.
    Caller-independence is proven by actually deploying twice from two senders with
    vm.snapshotState() / vm.revertToState() between them and comparing the two
    deployed addresses — not two predictions, which would only test the
    prediction function. The disjointness adversary is an account that can call
    every function on the factory with any arguments and land first.

  • Discriminating tests: test/src/concrete/CloneFactoryCloneDeterministicOpenSalt.t.sol,
    13 tests at 2048 fuzz runs each.

    test property
    …IsDomainTaggedHash reworked, was …SaltIsVerbatim and inverted by the redesign — the CREATE2 salt is keccak256(abi.encode(DOMAIN, salt, keccak256(data)))
    …DataInDerivation new — different data at the same salt is a different address, and both clones exist with their own initialization
    …DisjointFromNamespacedAtLeftPaddedAddressSalt new — the reachable squat the domain word closes, asserted as a live attack against the factory's own namespaced prediction
    …DiffersFromSenderNamespaced the broad disjointness claim under free inputs on both sides
    …MatchesPredict deployment lands at the prediction; is an EIP1167 proxy of the impl; initialized with data
    …CallerIndependent the point of the variant — two senders, same (impl, data, salt), same state → the SAME address
    …PredictCallerIndependent the prediction cannot vary with the caller
    …DoesNotConsumeNamespacedSalt regression guard: taking a salt openly does not block or move the namespaced address for the same salt
    …ManyClonesPerImpl distinct salts, distinct clones
    …SecondDeployReverts repeating the whole (impl, data, salt) — now the only way to aim at a taken address — reverts Errors.FailedDeployment rather than silently returning the existing clone, and the first clone's state is untouched
    …Event NewClone(sender, impl, child, salt, data) with the raw salt
    …InitializeFailureFails InitializationFailed, and the address is left with zero code (atomicity)
    …ZeroImplementationCodeSize ZeroImplementationCodeSize

    Stated honestly: …DiffersFromSenderNamespaced kills no mutant. A
    collision it could catch needs a keccak256 collision, so no realistic mutation
    makes it fail. It is kept as the plain form of the interface's claim and its
    NatSpec says so; the discrimination is carried by
    …DisjointFromNamespacedAtLeftPaddedAddressSalt, which is exactly why that
    test is constructed rather than fuzzed.

    Plus testCandidateDeployedBytecodeServesBothEntryPoints in
    LibCloneFactoryDeployCandidate.t.sol (updated for the new predict
    signature). It Zoltu-deploys the recorded CREATION_CODE (deliberately not
    new CloneFactory(), so the assertion is about the snapshot rather than the
    source) and calls all four entry points through the ICloneableFactoryV4 ABI,
    so a pin recording an address for bytecode that does not dispatch an entry
    point cannot pass silently. A byte scan of the runtime code would NOT prove
    this — a selector can sit in constant data without being dispatchable.

    The existing CloneFactoryCloneDeterministic.t.sol already pins the namespaced
    derivation (testCloneDeterministicSaltIsAbiEncodeHash) and its sender-scoping
    (testCloneDeterministicSenderScoped); the mutation run confirms both still
    kill, so they are left as-is rather than duplicated.

  • Mutations applied: 11, each applied to the committed tree, whole suite run,
    tree restored, baseline re-verified green. All 11 killed. Two liveness
    guards on the harness, because a mutation that silently fails to apply reads as
    a surviving mutant:

    • CODE-CHANGED — comments are stripped from CloneFactory.sol before and
      after each edit and the two must differ. The first run of this pass produced
      a false survivor for mutant 3 exactly this way: the regex matched the
      docstring that quotes the derivation before it reached the return. Anchored
      and re-run; every mutant below reports CODE-CHANGED: yes.
    • TOTALS — passed+failed equals the baseline's 33 on every single run, so the
      tests demonstrably ran rather than being filtered out or failing to compile.

    Killers listed are behavioural: the three codehash pins
    (testCandidateSelfConsistent, testDeployAddress, testExpectedCodeHash)
    trip on all eleven, but that is a bytecode oracle rather than evidence of
    behavioural coverage, so they are excluded from attribution.

    # mutation behavioural killers
    1 open salt drops the domain word (the untagged derivation #51 rejected) …IsDomainTaggedHash, …DisjointFromNamespacedAtLeftPaddedAddressSalt
    2 open salt drops data (the verbatim-salt design this PR replaces) …IsDomainTaggedHash, …DataInDerivation
    3 open salt swaps salt and keccak256(data) in the preimage …IsDomainTaggedHash
    4 open clone secretly namespaces the salt by msg.sender …CallerIndependent, …DataInDerivation, …DisjointFrom…, …MatchesPredict, …SecondDeployReverts
    5 open predict uses the namespaced derivation …CallerIndependent, …DataInDerivation, …DisjointFrom…, …IsDomainTaggedHash, …MatchesPredict, …PredictCallerIndependent
    6 open clone drops _requireImplementationCode …ZeroImplementationCodeSize
    7 open clone skips _initializeClone …DataInDerivation, …DisjointFrom…, …Event, …InitializeFailureFails, …MatchesPredict, …SecondDeployReverts
    8 initialize return code left unchecked …InitializeFailureFails + existing testCloneDeterministicInitializeFailureFails
    9 cloneDeterministic drops its sender namespacing existing testCloneDeterministicSenderScoped, testCloneDeterministicMatchesPredict + …DisjointFrom…, …DoesNotConsumeNamespacedSalt
    10 predictDeterministicAddress drops its namespacing existing testCloneDeterministicSaltIsAbiEncodeHash, testCloneDeterministicSenderScoped, testCloneDeterministicMatchesPredict + …DisjointFrom…, …DoesNotConsumeNamespacedSalt
    11 NewClone emits the effective salt instead of the raw salt …Event + existing testCloneDeterministicEvent

    Mutant 1 is the one this redesign exists for, and note which test catches it:
    …DiffersFromSenderNamespaced — the general fuzz over both derivations —
    survives it. Only the constructed left-padded-address case fails, which is
    the empirical form of "reachable, not merely improbable". Mutants 9 and 10 are
    the ones that matter for "do not break the existing guarantee", and the
    disjointness test guards that side too.

Local run (rainix sol-shell, with the reproduction dir above): forge test
excluding the five fork tests — 33 passed, 0 failed. forge build clean,
forge fmt --check clean, slither . reports 0 result(s) found, reuse lint
compliant, one contract per .sol file.

What changed and why

The branch previously specified salt used verbatim as the CREATE2 salt,
with data outside the derivation, and
predictDeterministicAddressOpenSalt(address implementation, bytes32 salt).

Under that shape the address did not encode data, so the first caller's data
was baked in permanently at an address that said nothing about it. Deployer
irrelevance therefore depended on every consumer choosing to pass empty data
and on every implementation's initialize being audited for whether it takes
authority from it — a convention to audit, not a property the signature held.

Hashing data into the salt collapses that: a front-runner passing different
data derives a different address and has deployed their own contract at their
own expense; one passing the same data has deployed exactly what was intended.
What is left to state is the tx.origin MUST NOT and the timing residual, and
#51 states those as rules on the interface rather than as an audit. What is left
to hold is the image disjointness, and that is this repo's job.

Three consequences reached this branch and are the substance of this update:

  1. _effectiveOpenSalt replaces the verbatim pass-through in both open entry
    points, and predictDeterministicAddressOpenSalt gains its data parameter.
  2. The bytecode moved, so src/generated/candidate/ and .gas-snapshot are
    regenerated and the pinned address and codehash both move (table above).
  3. The fuzz suite is reworked. …SaltIsVerbatim asserted the exact property the
    redesign deletes and inverts into …IsDomainTaggedHash; …SecondDeployReverts
    previously deployed with one data and re-deployed with another at the same
    salt, which under the new derivation is two different addresses and no longer
    reverts, so it now repeats the whole triple; …MatchesPredict,
    …CallerIndependent, …PredictCallerIndependent, …InitializeFailureFails,
    …DoesNotConsumeNamespacedSalt and testCandidateDeployedBytecodeServesBothEntryPoints
    all move to the three-argument predict. Two properties that did not exist
    before are owed and added: …DataInDerivation and
    …DisjointFromNamespacedAtLeftPaddedAddressSalt. The mutation pass recorded on
    this PR previously targeted the verbatim-salt code and no longer applies; the
    11 above replace it.

🤖 Generated with Claude Code

The deploy half of rainlanguage/rain.factory#51, which was written against the
pre-split rain.factory tree. Five of that PR's eight files targeted paths
rain.factory#47 deleted; they land here instead.

CloneFactory gains a second deterministic entry point whose CREATE2 salt is the
caller-supplied salt verbatim, so the clone address is
CREATE2(factory, salt, EIP1167(impl)) with no identity in the derivation:

  cloneDeterministicOpenSalt(address implementation, bytes data, bytes32 salt)
  predictDeterministicAddressOpenSalt(address implementation, bytes32 salt)

cloneDeterministic and predictDeterministicAddress are untouched: their
msg.sender namespacing is a guarantee other consumers rely on, so this is purely
additive and the two derivations are provably disjoint (a fuzz test asserts no
(implementation, salt, deployer) maps to the same address under both).
_requireImplementationCode and _initializeClone are reused as-is, so
clone-and-initialize stays atomic and the open variant's failure modes are
identical to the existing one.

The rain-factory Soldeer pin moves 0.1.5 -> 0.1.7 and every versioned import
prefix moves with it, because ICloneableFactoryV4 only exists from 0.1.7. That
version is not published until rain.factory#51 merges, so this branch cannot
resolve its dependencies until then.

No hand-authored src/generated/0_1_6/ dir is carried over. Under the rolling
candidate model this branch is stacked on, the snapshot that describes current
source is src/generated/candidate/, regenerated by BuildPointers; a numbered dir
is frozen only by script/cut-release.sh at tag time. candidate/ is regenerated
here and lands on the same address 0x19272bCcFcb032eaC545E74ADFa168fDeD3e8d83
and codehash 0x1a16009998834f07d5ccab032c39377f6528870eec5abd47817f9467187b4012
that #51's hand-authored 0_1_6 dir recorded.

The frozen-snapshot dirs 0_1_3, 0_1_4 and 0_1_5 are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 95300a2d-38de-4936-8866-46ad6073f65e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

claude added 2 commits August 13, 2026 22:11
Tracks the redesign of `ICloneableFactoryV4` at rainlanguage/rain.factory#51.
The open-salt `CREATE2` salt is no longer the caller-supplied salt verbatim; it
is

    keccak256(abi.encode(ICLONEABLE_FACTORY_V4_OPEN_SALT_DOMAIN, salt, keccak256(data)))

so the clone address commits to `data` and `predictDeterministicAddressOpenSalt`
takes `data` as an input.

The domain word holds `ICloneableFactoryV4`'s MUST NOT on the factory: without
it both derivations would be 64-byte preimages led by a caller-chosen word, and
any account `A` could squat every open salt equal to
`bytes32(uint256(uint160(A)))` through the inherited `cloneDeterministic`.

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

The reachable overlap between the two derivations is one equation, so the
mirror framing (a victim whose namespaced salt is `keccak256(P)`, reachable
untagged by an open call passing `P` as `data`) is stated on the existing test
rather than duplicated as a second one.

`…DiffersFromSenderNamespaced` kills no mutant — a collision it could catch
needs a keccak256 collision — and its NatSpec now says so, so the reader is not
misled about which test carries the discrimination.

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

Labels

ai:blocked-on AI producer: blocked on a dependency PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants