0006. Diamonds are created and initialized atomically through LatticeFactory
- Status: Accepted
- Date: #182 (2026-09-11); recorded 2026-10-09
Context
Lattice.initialize applies the initial cut and runs the init delegatecall. It
is first-caller-wins: whoever calls it first chooses every facet of the diamond, including its upgrade
path. Recipe scripts used to send the proxy creation and initialize as two transactions, so anyone
watching the mempool could initialize the proxy in between.
Options considered
From the design comparison in #182:
- Deployer-gated
initialize. Rejected: an immutable deployer check breaks generic CREATE2 deployers and atomic ERC-4337 and ERC-7702 flows, and under 7702 delegation or behind a proxy the "deployer" would be the implementation's. - Initialization in the constructor. Rejected: constructor arguments change the initcode, which breaks the factory's constant CREATE2 derivation, and account implementations must stay uninitialized.
- A new
LatticeDeployerhelper. Atomic, but it duplicates the factory path, adds a deployment per diamond and gives no caller-bound address prediction. - The existing
LatticeFactory. Chosen: one atomic path with caller-bound CREATE2 addresses and factory-level recipe checks.
Decision
- Every diamond is created and initialized in one transaction.
LatticeFactory.deploycreates the proxy with CREATE2 and callsinitializein the same call. - The CREATE2 salt is
keccak256(abi.encode(msg.sender, salt)), so only the caller can occupy its addresses. The proxy has no constructor arguments, so its initcode hash is constant. - A recipe's broadcasting
run()deploys throughBaseDeploy._assemble, which calls the factory and refuses an occupied address instead of taking the factory's idempotent return.DeployGovernedVault.deployAtomiccallsfactory.deploydirectly and does take the idempotent return, so its caller must pass a fresh salt per recipe. No script creates aLatticeand initializes it separately. - Smart accounts use their own factories (
AccountFactory,AccountFactory6900), which follow the same rule.
Consequences
- No mempool window exists between creation and initialization.
- The diamond address commits to the caller and salt, not to the recipe. A repeat
deploywith the same caller and salt returns the existing diamond and ignores the new recipe, so callers use one salt per recipe.deployStrict(#321) revertsLatticeFactory__AlreadyDeployedinstead, andDiamondDeployedcarries the hash of the applied recipe. - The factory is the caller of
initialize, so an init that grantsmsg.sendera role grants it to the factory. No shipped init does this;test_InitGrantingMsgSenderGrantsTheFactorypins the behaviour. - Hand-written deployments outside
BaseDeploymust keep creation and initialization atomic themselves.
Confirmation
make check-atomic-deployfails when anyscript/Solidity file containsnew Latticeor an.initialize(call. It then broadcasts a recipe to Anvil and asserts one factorydeploy, no standaloneinitialize, an initialized diamond, salt-reuse rejection and reuse of a configured factory.BaseDeployTestcovers_assemble(test_AssembleDeploysAndInitializesThroughFactory,test_AssembleRevertsWhenAddressAlreadyDeployed).- The add-a-module checklist forbids
new Latticeor a separateinitializecall inscript/.
References
- #182 (the design comparison and gas measurements)
LatticeFactory.sol,BaseDeploy.s.sol- Registry and factory threat model
- #176