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..806c08c8 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 @@ -105,10 +111,8 @@ 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, (FACTORY_GENESIS_ADMIN)) - ) + abi.encode(FactoryDeployer.predictImplAddress(), abi.encodeCall(LibidFactory.initialize, (ADMIN))) ); - assertEq(keccak256(expectedProxyInitCode), keccak256(FactoryDeployer.proxyInitCode())); + assertEq(keccak256(expectedProxyInitCode), keccak256(FactoryDeployer.proxyInitCode(ADMIN))); } }