Skip to content

Orchestrator instance uses nonce-derived CREATE on a permissionless deploy(); use the rain-factory clone factory the repo already depends on #326

Description

@thedavidmeister

ST0xOrchestratorBeaconSetDeployer.deploy() creates the orchestrator instance with a plain new BeaconProxy(...) — a CREATE whose address derives from the set-deployer's account nonce — and the function is explicitly permissionless ("Callable by anyone — no auth on the deployer", src/concrete/deploy/ST0xOrchestratorBeaconSetDeployer.sol:44-45).

PR #316 pins the resulting instance as LibOrchestratorInvariants.ST0X_ORCHESTRATOR_INSTANCE = 0x3A7387a4…, computed as computeCreateAddress(setDeployer, 2). That address is ours only if the ops broadcast wins a race to be the first deploy() call on each chain. The set-deployer is a Zoltu CREATE2 deploy at a chain-invariant address and is already live on Base, and this PR publishes the target address in a public repo before claiming it.

Consequences of losing that race, for one transaction's gas:

  • The attacker permanently holds 0x3A7387a4… with an attacker-admin BeaconProxy.
  • The script is permanently bricked — _assertNoInstanceYet reverts OrchestratorAlreadyDeployed forever, with no override path.
  • Recovery is worse than a redeploy: CLAUDE.md forbids updating an address constant in LibProdDeploy* in place, and the same set-deployer can never produce a nonce-2 CREATE again, so recovery needs a whole new orchestrator beacon-set deployer — new creation bytecode, new Zoltu address, new audit.

The fix

Deterministic cloning, which this repo already does elsewhere and already depends on.

script/20260619-deploy-v4-authoriser-clone.s.sol deploys its clone through ICloneableFactoryV2 + LibCloneFactoryDeploy from rain-factory-0.1.1, pinned in foundry.toml:65. The pattern, the dependency and a working in-repo precedent all exist; the orchestrator instance is the one that does not use them.

A clone factory derives the address from f(factory, implementation, initData) via CREATE2 rather than from a deployer nonce, which removes the race rather than guarding it. An attacker calling first has two options and neither takes the pin: deploy our exact config, which lands at the address we wanted with the owner we wanted and is the outcome we were going to produce anyway; or deploy different config, which lands at a different address and leaves the pin untouched.

It also dissolves two adjacent findings from the #316 review rather than patching them. _assertNoInstanceYet's nonce assumption disappears, because the address stops depending on how many times deploy() has been called. And the simulation-only post-condition gap (assertDeployLanded is an internal call, so it never lands in the broadcast transaction) stops being able to produce a stray unpinned instance, since a lost race cannot mint at a config-derived address.

This is also the org's stated rule: instances and per-token proxies use deterministic cloning (CREATE2 clones or a fixed-salt clone factory), never plain new / CREATE.

Cost

deploy() lives in ST0xOrchestratorBeaconSetDeployer, which is part of the audited 0.1.30 bytecode frozen by #314. Changing it is a new audited version, not a script edit — so this is a decision about the next audit round rather than something to patch in flight.

Out of scope here: the orchestrator beacon's owner being a hot deploy EOA with no migration window (LibOrchestratorInvariants.assertBeaconSet pins BEACON_INITIAL_OWNER permanently, and the governance-timelock bundle's fixed address[3] cannot reach a fourth beacon). That is about who holds upgrade authority, not how the instance address is derived, and wants its own issue.

Found by an adversarial audit of #316 at pr:316 scope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions