From 32626abeca492e85fd4b10450e2751ecf8e30f92 Mon Sep 17 00:00:00 2001 From: Green Baneling Date: Sun, 4 Oct 2026 01:34:18 +0100 Subject: [PATCH 1/2] feat(factory): the genesis admin is a parameter, one factory per environment The factory's proxy init code baked one admin constant, so every network shared one factory address and one deployer key. Each environment now has its own deployer key (testnet in libid-testnet, mainnet in libid-mainnet), so the admin becomes the one varying input: `FactoryDeployer.proxyInitCode(admin)` in Solidity, `FactoryGenesis { admin }` in Rust with `address` and `ensure`. Addresses stay a pure function of (admin, name), the same on every network one deployer deploys to; testnet and mainnet get different tables. Assisted-by: Claude Fable 5.1 Signed-off-by: Green Baneling --- rust/contracts/README.md | 2 +- rust/contracts/src/factory.rs | 183 +++++++++--------- rust/contracts/tests/anvil.rs | 74 ++----- .../contracts/factory/FactoryDeployer.sol | 61 +++--- solidity/contracts/factory/FactoryGenesis.sol | 25 --- solidity/contracts/factory/LibidFactory.sol | 15 +- solidity/contracts/factory/README.md | 46 ++--- .../factory/test/FactoryDeployer.t.sol | 24 ++- 8 files changed, 192 insertions(+), 238 deletions(-) delete mode 100644 solidity/contracts/factory/FactoryGenesis.sol diff --git a/rust/contracts/README.md b/rust/contracts/README.md index 079f9932..40d38974 100644 --- a/rust/contracts/README.md +++ b/rust/contracts/README.md @@ -146,7 +146,7 @@ Other entry points: - `deploy::upgrade_uups` — deploy a fresh implementation and `upgradeToAndCall` a UUPS proxy onto it. -- `factory::ensure_factory` / `factory::factory_deploy` — install the +- `factory::FactoryGenesis::ensure` / `factory::factory_deploy` — install the canonical cross-network factory where missing and deploy protocol proxies through it at name-derived CREATE3 addresses. - `circuits::version` — the `libid-circuits` release the vendored verifiers diff --git a/rust/contracts/src/factory.rs b/rust/contracts/src/factory.rs index 48ff49db..0bd45191 100644 --- a/rust/contracts/src/factory.rs +++ b/rust/contracts/src/factory.rs @@ -1,18 +1,18 @@ //! Bootstrap and use of the deterministic deployment factory. //! -//! The [`LibidFactory`] proxy lives at the same address on every EVM network -//! because every byte that feeds its address is frozen: it is deployed -//! through the canonical keyless CREATE2 deployer (Arachnid's +//! An environment's [`LibidFactory`] proxy lives at the same address on every +//! EVM network because every byte that feeds its address is frozen but one: +//! it is deployed through the canonical keyless CREATE2 deployer (Arachnid's //! deterministic-deployment proxy at [`CREATE2_DEPLOYER`]) with fixed salts -//! and init codes that carry no per-network data — the admin is the baked -//! [`FACTORY_GENESIS_ADMIN`] constant, so initialization happens atomically -//! inside the deployment. Protocol proxies are then deployed *through* the -//! factory via CREATE3, which makes their addresses a function of -//! `(factory, name)` only — see `solidity/contracts/factory/README.md`. +//! and init codes whose only input is the genesis admin, the environment's +//! deployer key ([`FactoryGenesis`]), baked in so that initialization happens +//! atomically inside the deployment. Protocol proxies are then deployed +//! *through* the factory via CREATE3, which makes their addresses a function +//! of `(factory, name)` only — see `solidity/contracts/factory/README.md`. //! -//! [`ensure_factory`] is the whole bootstrap: check → install the CREATE2 -//! deployer if missing (via its well-known presigned transaction) → deploy -//! the factory impl and proxy at their canonical addresses. There is +//! [`FactoryGenesis::ensure`] is the whole bootstrap: check → install the +//! CREATE2 deployer if missing (via its well-known presigned transaction) → +//! deploy the factory impl and proxy at their canonical addresses. There is //! deliberately no fallback deployment path: anything else would change the //! factory address and defeat the cross-network guarantee, so a chain that //! cannot take the presigned install transaction is a hard error. @@ -74,12 +74,6 @@ pub const CREATE2_DEPLOYER_FUNDING_WEI: u128 = 10_000_000_000_000_000; /// alternate deployment path. pub const CREATE2_DEPLOYER_INSTALL_TX: &str = "0xf8a58085174876e800830186a08080b853604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf31ba02222222222222222222222222222222222222222222222222222222222222222a02222222222222222222222222222222222222222222222222222222222222222"; -/// The genesis admin baked into the frozen factory-proxy init code. -/// PLACEHOLDER until the owner substitutes the protocol-admin KMS address. -/// Keep in sync with `solidity/contracts/factory/FactoryGenesis.sol`. -pub const FACTORY_GENESIS_ADMIN: Address = - address!("5bb76B0f81F028de363150602cC6d0Ca929E3C31"); - /// The 16-byte CREATE3 proxy init code (`Create3.PROXY_INITCODE`). Constant /// forever — its hash feeds every predicted address. pub const CREATE3_PROXY_INITCODE: [u8; 16] = [ @@ -109,25 +103,93 @@ pub fn predict_factory_impl_address(artifacts: &Artifacts) -> Result
{ Ok(CREATE2_DEPLOYER.create2(factory_impl_salt(), keccak256(&init_code))) } -/// The frozen proxy init code: ERC1967Proxy creation code ++ -/// abi.encode(implAddress, initialize(FACTORY_GENESIS_ADMIN)). Every byte is -/// network-invariant; `FactoryDeployer.proxyInitCode()` produces the same -/// bytes (asserted against the vendored artifacts by the Solidity tests). -pub fn factory_proxy_init_code(artifacts: &Artifacts) -> Result { - let impl_addr = predict_factory_impl_address(artifacts)?; - let init_data = LibidFactory::initializeCall { - owner_: FACTORY_GENESIS_ADMIN, - } - .abi_encode(); - let mut code = artifacts.bytecode("ERC1967Proxy")?.to_vec(); - code.extend_from_slice(&(impl_addr, Bytes::from(init_data)).abi_encode_params()); - Ok(code.into()) +/// The factory of one environment, pinned by its genesis admin: the deployer +/// key that owns the factory from its first block. The admin is the only +/// varying input of the frozen proxy init code, so the factory's address, and +/// every address deployed through it, follow from the admin and nothing +/// else. Testnet and mainnet have their own. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FactoryGenesis { + /// The deployer key: factory owner from genesis, `apply`'s signer. + pub admin: Address, } -/// The canonical factory address — the same on every EVM network. -pub fn predict_factory_address(artifacts: &Artifacts) -> Result
{ - let init_code = factory_proxy_init_code(artifacts)?; - Ok(CREATE2_DEPLOYER.create2(factory_proxy_salt(), keccak256(&init_code))) +impl FactoryGenesis { + /// The frozen proxy init code: ERC1967Proxy creation code ++ + /// abi.encode(implAddress, initialize(admin)). `FactoryDeployer.proxyInitCode(admin)` + /// produces the same bytes (asserted against the vendored artifacts by + /// the Solidity tests). + pub fn proxy_init_code(&self, artifacts: &Artifacts) -> Result { + let impl_addr = predict_factory_impl_address(artifacts)?; + let init_data = LibidFactory::initializeCall { owner_: self.admin }.abi_encode(); + let mut code = artifacts.bytecode("ERC1967Proxy")?.to_vec(); + code.extend_from_slice(&(impl_addr, Bytes::from(init_data)).abi_encode_params()); + Ok(code.into()) + } + + /// The factory's address — the same on every EVM network the admin + /// deploys to. + pub fn address(&self, artifacts: &Artifacts) -> Result
{ + let init_code = self.proxy_init_code(artifacts)?; + Ok(CREATE2_DEPLOYER.create2(factory_proxy_salt(), keccak256(&init_code))) + } + + /// Make sure the factory exists at [`Self::address`], bootstrapping + /// whatever is missing: the CREATE2 deployer (via the keyless presigned + /// transaction), the factory implementation, and the factory proxy — + /// each at its deterministic address. Idempotent: reruns are read-only + /// no-ops once the factory is up. + pub async fn ensure( + &self, + provider: &P, + artifacts: &Artifacts, + ) -> Result
{ + let factory = self.address(artifacts)?; + let code = provider + .get_code_at(factory) + .await + .map_err(|e| Error::Rpc { + detail: format!("failed to read code at the factory address: {e}"), + })?; + if !code.is_empty() { + return Ok(factory); + } + + ensure_create2_deployer(provider).await?; + + let impl_addr = predict_factory_impl_address(artifacts)?; + let impl_code = + provider + .get_code_at(impl_addr) + .await + .map_err(|e| Error::Rpc { + detail: format!( + "failed to read code at the factory impl address: {e}" + ), + })?; + if impl_code.is_empty() { + deploy_via_create2( + provider, + factory_impl_salt(), + &factory_impl_init_code(artifacts)?, + impl_addr, + "LibidFactory (impl)", + None, + ) + .await?; + } + + deploy_via_create2( + provider, + factory_proxy_salt(), + &self.proxy_init_code(artifacts)?, + factory, + "LibidFactory (proxy)", + None, + ) + .await?; + Ok(factory) + } } /// The CREATE3 address `factory.deploy(name, ·)` lands on: the factory @@ -216,59 +278,6 @@ pub async fn ensure_create2_deployer(provider: &P) -> Result<()> { Ok(()) } -/// Make sure the canonical factory exists at [`predict_factory_address`], -/// bootstrapping whatever is missing: the CREATE2 deployer (via the keyless -/// presigned transaction), the factory implementation, and the factory -/// proxy — each at its deterministic address. Idempotent: reruns are -/// read-only no-ops once the factory is up. -pub async fn ensure_factory( - provider: &P, - artifacts: &Artifacts, -) -> Result
{ - let factory = predict_factory_address(artifacts)?; - let code = provider - .get_code_at(factory) - .await - .map_err(|e| Error::Rpc { - detail: format!("failed to read code at the factory address: {e}"), - })?; - if !code.is_empty() { - return Ok(factory); - } - - ensure_create2_deployer(provider).await?; - - let impl_addr = predict_factory_impl_address(artifacts)?; - let impl_code = provider - .get_code_at(impl_addr) - .await - .map_err(|e| Error::Rpc { - detail: format!("failed to read code at the factory impl address: {e}"), - })?; - if impl_code.is_empty() { - deploy_via_create2( - provider, - factory_impl_salt(), - &factory_impl_init_code(artifacts)?, - impl_addr, - "LibidFactory (impl)", - None, - ) - .await?; - } - - deploy_via_create2( - provider, - factory_proxy_salt(), - &factory_proxy_init_code(artifacts)?, - factory, - "LibidFactory (proxy)", - None, - ) - .await?; - Ok(factory) -} - /// Deploy `creation_code` under `name` through the factory (the provider's /// wallet must be the factory owner). Returns the deployed address, which /// always equals [`predict_address`]`(factory, name)`. diff --git a/rust/contracts/tests/anvil.rs b/rust/contracts/tests/anvil.rs index 9c7f5c1b..ceb9449c 100644 --- a/rust/contracts/tests/anvil.rs +++ b/rust/contracts/tests/anvil.rs @@ -250,11 +250,12 @@ async fn rotates_and_upgrades_the_notary_service() { } /// (c) The deterministic factory, from truly nothing: anvil is started -/// WITHOUT its predeployed CREATE2 deployer, so `ensure_factory` must -/// install it via the keyless presigned transaction (funding the one-time -/// signer first), then deploy the factory impl + proxy at their canonical -/// addresses. Then a Notary Service proxy goes through the factory at its -/// name-derived CREATE3 address. +/// WITHOUT its predeployed CREATE2 deployer, so `FactoryGenesis::ensure` +/// must install it via the keyless presigned transaction (funding the +/// one-time signer first), then deploy the factory impl + proxy at their +/// canonical addresses, owned by the genesis admin from the first block. +/// Then a Notary Service proxy goes through the factory at its name-derived +/// CREATE3 address. #[tokio::test] async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { use alloy::{ @@ -267,12 +268,10 @@ async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { use libid_contracts::{ bindings::factory::LibidFactory, factory::{ - ensure_factory, factory_deploy, predict_address, - predict_factory_address, + FactoryGenesis, CREATE2_DEPLOYER, - FACTORY_GENESIS_ADMIN, }, }; @@ -283,6 +282,7 @@ async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { .expect("anvil spawns"); let artifacts = Artifacts::embedded(); let deployer0 = default_signer(&provider).await; + let genesis = FactoryGenesis { admin: deployer0 }; // Truly bare chain: no CREATE2 deployer. assert!(provider @@ -291,64 +291,26 @@ async fn bootstraps_the_deterministic_factory_and_deploys_through_it() { .unwrap() .is_empty()); - let factory = ensure_factory(&provider, &artifacts).await.unwrap(); - assert_eq!(factory, predict_factory_address(&artifacts).unwrap()); + let factory = genesis.ensure(&provider, &artifacts).await.unwrap(); + assert_eq!(factory, genesis.address(&artifacts).unwrap()); assert!(!provider.get_code_at(factory).await.unwrap().is_empty()); - // The instant the proxy exists it is owned by the baked genesis admin — + // The instant the proxy exists it is owned by its genesis admin — // initialization was atomic with deployment. let factory_contract = LibidFactory::new(factory, &provider); - assert_eq!( - factory_contract.owner().call().await.unwrap(), - FACTORY_GENESIS_ADMIN - ); + assert_eq!(factory_contract.owner().call().await.unwrap(), deployer0); // Rerun = read-only no-op. assert_eq!( - ensure_factory(&provider, &artifacts).await.unwrap(), + genesis.ensure(&provider, &artifacts).await.unwrap(), factory ); - // Hand ownership to the test signer: impersonate the genesis admin - // (a placeholder address nobody holds a key for) through anvil. - provider - .raw_request::<_, serde_json::Value>( - "anvil_setBalance".into(), - (FACTORY_GENESIS_ADMIN, "0xde0b6b3a7640000"), - ) - .await - .unwrap(); - provider - .raw_request::<_, serde_json::Value>( - "anvil_impersonateAccount".into(), - (FACTORY_GENESIS_ADMIN,), - ) - .await - .unwrap(); - let transfer = LibidFactory::transferOwnershipCall { - newOwner: deployer0, - } - .abi_encode(); - provider - .raw_request::<_, serde_json::Value>( - "eth_sendTransaction".into(), - (serde_json::json!({ - "from": FACTORY_GENESIS_ADMIN, - "to": factory, - "data": Bytes::from(transfer), - }),), - ) - .await - .unwrap(); - factory_contract - .acceptOwnership() - .send() - .await - .unwrap() - .get_receipt() - .await - .unwrap(); - assert_eq!(factory_contract.owner().call().await.unwrap(), deployer0); + // Another admin is another factory, at another address. + let other = FactoryGenesis { + admin: Address::repeat_byte(0x22), + }; + assert_ne!(other.address(&artifacts).unwrap(), factory); // A Notary Service PROXY through the factory: impl via plain CREATE (its // address doesn't matter), proxy creation code = ERC1967Proxy ++ (impl, diff --git a/solidity/contracts/factory/FactoryDeployer.sol b/solidity/contracts/factory/FactoryDeployer.sol index 02f49c85..6580ca96 100644 --- a/solidity/contracts/factory/FactoryDeployer.sol +++ b/solidity/contracts/factory/FactoryDeployer.sol @@ -3,11 +3,10 @@ pragma solidity ^0.8.20; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; -import {FACTORY_GENESIS_ADMIN} from "./FactoryGenesis.sol"; import {LibidFactory} from "./LibidFactory.sol"; -/// @title FactoryDeployer — the frozen bootstrap material for the canonical -/// factory, and the math that predicts its address. +/// @title FactoryDeployer — the frozen bootstrap material for a factory, and +/// the math that predicts its address from its genesis admin. /// /// @notice The factory (impl AND proxy) is deployed through the canonical /// keyless CREATE2 deployer (Arachnid's deterministic-deployment @@ -15,31 +14,33 @@ import {LibidFactory} from "./LibidFactory.sol"; /// standard EVM chain), with fixed salts and FROZEN init codes: /// /// implAddress = CREATE2(CREATE2_DEPLOYER, IMPL_SALT, implInitCode) -/// factoryAddr = CREATE2(CREATE2_DEPLOYER, PROXY_SALT, proxyInitCode) +/// factoryAddr = CREATE2(CREATE2_DEPLOYER, PROXY_SALT, proxyInitCode(admin)) /// /// `implInitCode` is the LibidFactory creation code (no constructor -/// args). `proxyInitCode` is the ERC1967Proxy creation code with -/// `(implAddress, abi.encodeCall(initialize, (FACTORY_GENESIS_ADMIN)))` -/// appended — the impl address is itself deterministic, and the admin -/// is a baked constant, so the proxy's constructor args are -/// network-invariant. Result: `predictFactoryAddress()` is the same -/// on every EVM network, and the proxy initializes atomically inside -/// its own deployment (no front-run window). +/// args). `proxyInitCode(admin)` is the ERC1967Proxy creation code +/// with `(implAddress, abi.encodeCall(initialize, (admin)))` appended +/// — the impl address is itself deterministic, so the only input is +/// the genesis admin: the deployer key that owns the factory from its +/// first block. Result: `predictFactoryAddress(admin)` is the same on +/// every EVM network that admin deploys to, and the proxy initializes +/// atomically inside its own deployment (no front-run window). One +/// admin per environment (testnet, mainnet) means one factory address +/// and one address table per environment. /// /// @dev THE INIT CODES ARE FROZEN. They are pinned by the compiler settings /// (solc 0.8.33, via_ir, 200 runs, no CBOR metadata) and by the sources; /// `test/FactoryDeployer.t.sol` asserts the built artifacts match /// `type(·).creationCode`, and the vendored artifacts in /// `rust/contracts/artifacts` carry the same bytes. Changing -/// LibidFactory's source, the admin constant, or the compiler settings -/// changes the init code and therefore the canonical address — that is a -/// v2 factory and MUST use new salts (`libid.factory{,.impl}.v2`), never -/// a silent replacement of v1. Factory-behavior changes that should NOT -/// move the address go through the normal UUPS upgrade instead. +/// LibidFactory's source or the compiler settings changes the init code +/// and therefore every factory address — that is a v2 factory and MUST +/// use new salts (`libid.factory{,.impl}.v2`), never a silent +/// replacement of v1. Factory-behavior changes that should NOT move the +/// address go through the normal UUPS upgrade instead. /// /// To deploy on a new network, send two transactions to /// `CREATE2_DEPLOYER` (its calldata format is `salt ++ initCode`): -/// first `implDeployCalldata()`, then `proxyDeployCalldata()`. +/// first `implDeployCalldata()`, then `proxyDeployCalldata(admin)`. /// `rust/contracts/src/factory.rs::ensure_factory` automates this, /// including installing the CREATE2 deployer itself via its well-known /// presigned transaction where it is missing. @@ -67,19 +68,19 @@ library FactoryDeployer { return _create2Address(CREATE2_DEPLOYER, IMPL_SALT, keccak256(implInitCode())); } - /// The frozen proxy init code: ERC1967Proxy creation code ++ - /// abi.encode(implAddress, initialize(FACTORY_GENESIS_ADMIN)). Every byte - /// of it is network-invariant. - function proxyInitCode() internal pure returns (bytes memory) { + /// The frozen proxy init code of the factory `admin` owns from genesis: + /// ERC1967Proxy creation code ++ abi.encode(implAddress, initialize(admin)). + /// The admin is its only input that varies. + function proxyInitCode(address admin) internal pure returns (bytes memory) { return abi.encodePacked( type(ERC1967Proxy).creationCode, - abi.encode(predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (FACTORY_GENESIS_ADMIN))) + abi.encode(predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (admin))) ); } - /// The canonical factory address — the same on every EVM network. - function predictFactoryAddress() internal pure returns (address) { - return _create2Address(CREATE2_DEPLOYER, PROXY_SALT, keccak256(proxyInitCode())); + /// The address of `admin`'s factory — the same on every EVM network. + function predictFactoryAddress(address admin) internal pure returns (address) { + return _create2Address(CREATE2_DEPLOYER, PROXY_SALT, keccak256(proxyInitCode(admin))); } /// Calldata for `CREATE2_DEPLOYER` that deploys the implementation. @@ -88,11 +89,11 @@ library FactoryDeployer { } /// Calldata for `CREATE2_DEPLOYER` that deploys (and atomically - /// initializes) the factory proxy. Requires the implementation to exist - /// already: ERC1967Proxy's constructor refuses an implementation with no - /// code. - function proxyDeployCalldata() internal pure returns (bytes memory) { - return abi.encodePacked(PROXY_SALT, proxyInitCode()); + /// initializes) `admin`'s factory proxy. Requires the implementation to + /// exist already: ERC1967Proxy's constructor refuses an implementation + /// with no code. + function proxyDeployCalldata(address admin) internal pure returns (bytes memory) { + return abi.encodePacked(PROXY_SALT, proxyInitCode(admin)); } function _create2Address(address deployer, bytes32 salt, bytes32 initCodeHash) private pure returns (address) { diff --git a/solidity/contracts/factory/FactoryGenesis.sol b/solidity/contracts/factory/FactoryGenesis.sol deleted file mode 100644 index c8f6b1c5..00000000 --- a/solidity/contracts/factory/FactoryGenesis.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -// The protocol-admin address baked into the FROZEN factory-proxy init code. -// -// The canonical factory proxy is deployed with init code that ends in -// `abi.encode(implAddress, abi.encodeCall(LibidFactory.initialize, (FACTORY_GENESIS_ADMIN)))` -// (see `FactoryDeployer.proxyInitCode()`). Baking the admin into the init -// code — instead of passing it per network — is what makes the init code -// identical everywhere (same CREATE2 address on every chain) AND makes -// initialization atomic with deployment: there is no window in which an -// uninitialized proxy exists for someone to front-run `initialize`. -// -// ───────────────────────────────────────────────────────────────────────── -// PLACEHOLDER — DO NOT DEPLOY TO ANY MAINNET-FAMILY NETWORK AS-IS. -// -// The owner substitutes the real protocol-admin KMS address here BEFORE the -// first mainnet-family deployment. Changing this constant changes the frozen -// proxy init code and therefore the canonical factory address, so it must be -// set once, before anything ships, and then NEVER touched again (a later -// change is a v2 factory: new salts, new address — see the README). -// -// Keep in sync with `FACTORY_GENESIS_ADMIN` in `rust/contracts/src/factory.rs`. -// ───────────────────────────────────────────────────────────────────────── -address constant FACTORY_GENESIS_ADMIN = 0x5bb76B0f81F028de363150602cC6d0Ca929E3C31; diff --git a/solidity/contracts/factory/LibidFactory.sol b/solidity/contracts/factory/LibidFactory.sol index 751bb208..8abdfcd4 100644 --- a/solidity/contracts/factory/LibidFactory.sol +++ b/solidity/contracts/factory/LibidFactory.sol @@ -24,13 +24,14 @@ import {Create3} from "./Create3.sol"; /// @dev UUPS + Ownable2Step behind an ERC1967 proxy, like every other /// upgradeable contract in the stack. Two things are special here: /// -/// - The canonical deployment path does NOT choose an owner per network. -/// The proxy init code is FROZEN with `initialize(FACTORY_GENESIS_ADMIN)` -/// baked in (see `FactoryGenesis.sol` / `FactoryDeployer.proxyInitCode()`), +/// - The canonical deployment path chooses the owner once per +/// environment, not per network. The proxy init code is FROZEN with +/// `initialize(admin)` baked in (see `FactoryDeployer.proxyInitCode`), /// so deployment and initialization are one atomic CREATE2 — no /// front-run window — and the init code (hence the address) is -/// identical on every chain. `initialize` exists per UUPS convention -/// and for tests; on the canonical path nobody ever calls it manually. +/// identical on every chain that admin deploys to. `initialize` +/// exists per UUPS convention and for tests; on the canonical path +/// nobody ever calls it manually. /// - Upgrading this factory keeps its address and its deployment /// records; everything deployed through it stays where it is. contract LibidFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable { @@ -51,8 +52,8 @@ contract LibidFactory is Initializable, UUPSUpgradeable, Ownable2StepUpgradeable /// @param owner_ Address that will own the factory (deploy + upgrade /// authority; should be a multisig / KMS key). The - /// canonical path bakes `FACTORY_GENESIS_ADMIN` here via - /// the frozen proxy init code. + /// canonical path bakes the environment's deployer key + /// here via the frozen proxy init code. function initialize(address owner_) external initializer { __Ownable_init(owner_); __Ownable2Step_init(); diff --git a/solidity/contracts/factory/README.md b/solidity/contracts/factory/README.md index ecb15108..a4663243 100644 --- a/solidity/contracts/factory/README.md +++ b/solidity/contracts/factory/README.md @@ -1,18 +1,20 @@ # Deterministic deployment factory One guarantee: **a protocol contract's address is a function of its name -alone** — `address = f(name)` — the same on every EVM network, before anything -is deployed there, forever. Not a function of transaction order, deployer -nonces, bytecode versions, or constructor arguments. +and its environment's deployer key alone** — `address = f(admin, name)` — +the same on every EVM network that deployer deploys to, before anything is +deployed there, forever. Not a function of transaction order, deployer +nonces, bytecode versions, or constructor arguments. One deployer key per +environment (testnet, mainnet) gives one address table per environment. ``` Arachnid CREATE2 deployer (0x4e59b4…956C, keyless, same address everywhere) │ fixed salt keccak("libid.factory.impl.v1") + frozen impl init code ├──▶ LibidFactory implementation - │ fixed salt keccak("libid.factory.v1") + frozen proxy init code - └──▶ LibidFactory proxy ← THE canonical factory, same address on every chain + │ fixed salt keccak("libid.factory.v1") + frozen proxy init code (admin) + └──▶ LibidFactory proxy ← the environment's factory, same address on every chain │ CREATE3, salt = keccak(bytes(name)), owner-gated - └──▶ every protocol proxy: address = f(factory, name) = f(name) + └──▶ every protocol proxy: address = f(factory, name) = f(admin, name) ``` ## Why CREATE3, not raw CREATE2 @@ -35,18 +37,17 @@ their proxy and don't need to be deterministic. ## Why the admin is baked in (atomic initialization) The factory's own proxy init code is **frozen** -([`FactoryDeployer.proxyInitCode()`](./FactoryDeployer.sol)): ERC1967Proxy -creation code ++ `abi.encode(implAddress, abi.encodeCall(initialize, -(FACTORY_GENESIS_ADMIN)))`. No per-network constructor args means the same -CREATE2 address everywhere, and it means the proxy initializes *inside its -own deployment transaction*: at no block height does an uninitialized factory +([`FactoryDeployer.proxyInitCode(admin)`](./FactoryDeployer.sol)): +ERC1967Proxy creation code ++ `abi.encode(implAddress, +abi.encodeCall(initialize, (admin)))`. The admin is the environment's +deployer key, the only input that varies, so the same key gets the same +CREATE2 address everywhere, and the proxy initializes *inside its own +deployment transaction*: at no block height does an uninitialized factory exist, so there is nothing to front-run. The raw implementation is bricked with `_disableInitializers()`. -`FACTORY_GENESIS_ADMIN` ([`FactoryGenesis.sol`](./FactoryGenesis.sol)) is a -**placeholder** until the owner substitutes the real protocol-admin KMS -address — that must happen *before* the first mainnet-family deployment, -because changing it afterwards changes the canonical address. +The admin is chosen once per environment: changing it afterwards is a new +factory at a new address, with a new address table behind it. ## Bootstrap: the keyless CREATE2 deployer is the only path @@ -58,7 +59,7 @@ signature was fixed before any key existed), so it has the same address on every chain. On a chain where it is missing, it is installable by anyone: fund `0x3fab184622dc19b6109349b94811493bf2a45362` with exactly 0.01 ETH (100 gwei × 100 000 gas) and broadcast the well-known raw transaction — -`ensure_factory` in `rust/contracts/src/factory.rs` automates the whole +`FactoryGenesis::ensure` in `rust/contracts/src/factory.rs` automates the whole sequence (install deployer if absent → deploy impl → deploy proxy). **There is deliberately no fallback path.** Any other deployment route (e.g. @@ -67,7 +68,7 @@ silently defeat the cross-network guarantee. Network onboarding requirement: the canonical CREATE2 deployer must be present or installable. A chain that enforces EIP-155 on all transactions (cannot accept the presigned install tx) and doesn't ship the deployer in genesis **cannot host the deterministic -factory** — `ensure_factory` fails hard, and the network must be +factory** — `FactoryGenesis::ensure` fails hard, and the network must be reconsidered. Chains that changed CREATE2/CREATE address derivation (zkSync-Era-style) are out of scope for address parity. First supported network: **Eden testnet** (chain 3735928814), where the deployer is already @@ -75,13 +76,12 @@ present. ## The frozen-init-code invariant -`implInitCode()` and `proxyInitCode()` must **never change**: they are pinned -by the LibidFactory/ERC1967Proxy sources, the `FACTORY_GENESIS_ADMIN` -constant, and the compiler settings (solc 0.8.33, via_ir, 200 runs, no CBOR -metadata). `test/FactoryDeployer.t.sol` asserts they match the built +`implInitCode()` and `proxyInitCode(admin)` must **never change** for a +given admin: they are pinned by the LibidFactory/ERC1967Proxy sources and +the compiler settings (solc 0.8.33, via_ir, 200 runs, no CBOR metadata). `test/FactoryDeployer.t.sol` asserts they match the built artifacts byte-for-byte, which are in turn vendored for the Rust bootstrap — one set of bytes everywhere. Behavior changes to the live factory go through its UUPS upgrade (address and records are kept). A change that *does* alter -the init code — new admin, new sources you want deployed as the genesis -bytes — is a **v2 factory**: new salts (`libid.factory{,.impl}.v2`), a new +the init code — new sources you want deployed as the genesis bytes — is a +**v2 factory**: new salts (`libid.factory{,.impl}.v2`), a new canonical address, and a migration story; never a silent replacement of v1. diff --git a/solidity/contracts/factory/test/FactoryDeployer.t.sol b/solidity/contracts/factory/test/FactoryDeployer.t.sol index 6499bf68..f8c3a48e 100644 --- a/solidity/contracts/factory/test/FactoryDeployer.t.sol +++ b/solidity/contracts/factory/test/FactoryDeployer.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; -import {FACTORY_GENESIS_ADMIN} from "../FactoryGenesis.sol"; import {FactoryDeployer} from "../FactoryDeployer.sol"; import {LibidFactory} from "../LibidFactory.sol"; import {Ping} from "./Create3.t.sol"; @@ -14,6 +13,10 @@ import {Ping} from "./Create3.t.sol"; /// impl and proxy init codes are deployed through it exactly as /// `ensure_factory` does on a real network. contract FactoryDeployerTest is Test { + /// The deployer key of this test's environment: the factory's owner from + /// its first block. + address internal constant ADMIN = address(0xAD1); + /// The runtime bytecode of the deterministic-deployment proxy — the code /// that lives at 0x4e59b4…956C on every chain it was installed on. /// Calldata format: 32-byte salt ++ init code; returns the 20-byte @@ -39,29 +42,32 @@ contract FactoryDeployerTest is Test { bool ok; (ok, impl) = _deployVia(FactoryDeployer.implDeployCalldata()); assertTrue(ok, "impl deploy failed"); - (ok, factoryAddr) = _deployVia(FactoryDeployer.proxyDeployCalldata()); + (ok, factoryAddr) = _deployVia(FactoryDeployer.proxyDeployCalldata(ADMIN)); assertTrue(ok, "proxy deploy failed"); } function test_bootstrap_landsOnThePredictedAddresses() public { (address impl, address factoryAddr) = _bootstrap(); assertEq(impl, FactoryDeployer.predictImplAddress()); - assertEq(factoryAddr, FactoryDeployer.predictFactoryAddress()); + assertEq(factoryAddr, FactoryDeployer.predictFactoryAddress(ADMIN)); assertGt(impl.code.length, 0); assertGt(factoryAddr.code.length, 0); } /// The anti-front-running property: the instant the proxy exists, it is - /// already initialized with the baked genesis admin. There is no + /// already initialized with its genesis admin. There is no /// deploy-then-initialize gap. + /// A different admin is a different factory, at a different address. function test_bootstrap_ownerIsTheGenesisAdminAtomically() public { (, address factoryAddr) = _bootstrap(); LibidFactory factory = LibidFactory(factoryAddr); - assertEq(factory.owner(), FACTORY_GENESIS_ADMIN); + assertEq(factory.owner(), ADMIN); // Nobody can re-initialize the proxy… vm.expectRevert(); factory.initialize(address(this)); + + assertTrue(FactoryDeployer.predictFactoryAddress(address(0xAD2)) != factoryAddr); } /// …and the raw implementation is bricked by _disableInitializers. @@ -75,7 +81,7 @@ contract FactoryDeployerTest is Test { /// the proxy cannot be deployed ahead of the impl (wrong order fails /// loudly instead of minting a broken factory). function test_bootstrap_proxyBeforeImplFails() public { - (bool ok,) = _deployVia(FactoryDeployer.proxyDeployCalldata()); + (bool ok,) = _deployVia(FactoryDeployer.proxyDeployCalldata(ADMIN)); assertFalse(ok); } @@ -84,7 +90,7 @@ contract FactoryDeployerTest is Test { LibidFactory factory = LibidFactory(factoryAddr); address predicted = factory.predict("libid.thing"); - vm.prank(FACTORY_GENESIS_ADMIN); + vm.prank(ADMIN); assertEq(factory.deploy("libid.thing", type(Ping).creationCode), predicted); vm.prank(address(this)); // not the admin @@ -106,9 +112,9 @@ contract FactoryDeployerTest is Test { bytes memory expectedProxyInitCode = abi.encodePacked( proxyArtifact, abi.encode( - FactoryDeployer.predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (FACTORY_GENESIS_ADMIN)) + FactoryDeployer.predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (ADMIN)) ) ); - assertEq(keccak256(expectedProxyInitCode), keccak256(FactoryDeployer.proxyInitCode())); + assertEq(keccak256(expectedProxyInitCode), keccak256(FactoryDeployer.proxyInitCode(ADMIN))); } } From cc7fce8adbf882a466be9e40fa659662029db058 Mon Sep 17 00:00:00 2001 From: Green Baneling Date: Sun, 4 Oct 2026 01:45:21 +0100 Subject: [PATCH 2/2] style(factory): forge fmt Assisted-by: Claude Fable 5.1 Signed-off-by: Green Baneling --- solidity/contracts/factory/test/FactoryDeployer.t.sol | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/solidity/contracts/factory/test/FactoryDeployer.t.sol b/solidity/contracts/factory/test/FactoryDeployer.t.sol index f8c3a48e..806c08c8 100644 --- a/solidity/contracts/factory/test/FactoryDeployer.t.sol +++ b/solidity/contracts/factory/test/FactoryDeployer.t.sol @@ -111,9 +111,7 @@ contract FactoryDeployerTest is Test { vm.parseJsonBytes(vm.readFile("out/ERC1967Proxy.sol/ERC1967Proxy.json"), ".bytecode.object"); bytes memory expectedProxyInitCode = abi.encodePacked( proxyArtifact, - abi.encode( - FactoryDeployer.predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (ADMIN)) - ) + abi.encode(FactoryDeployer.predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (ADMIN))) ); assertEq(keccak256(expectedProxyInitCode), keccak256(FactoryDeployer.proxyInitCode(ADMIN))); }