From c48170a6031314e120494a2ec48341842a983d40 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:09:04 +0000 Subject: [PATCH 1/2] feat(registry): AddressRegistry, the write-once IAddressRegistryV1 concrete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An immutable root authority binds an opaque `bytes32` name to an address, once; nothing, root included, can change one after; and reading an unbound name reverts rather than answering with the zero address, so no caller has to remember to check. That is the whole contract. No rotation, no removal, no upgrade, no admin surface and no second reader — each of those turns a binding from a constant back into a value that can move, which is the one property the registry exists to provide, and the property that makes a deploy-time check of a binding worth anything. `ADDRESS_REGISTRY_ROOT` is a PLACEHOLDER. The root is a constant in the creation code, so it is part of the contract's identity: changing it moves the deterministic deploy address and code hash on every network. No deploy-pin snapshot, deploy suite or pin lib is generated here for that reason, and `AddressRegistryDeployPinsTest` fails if this source and `rain-deploy`'s `LibAddressRegistry` pins ever disagree. The interface comes from `rain-deploy`, which the bump from 0.1.3 to 0.1.6 brings in. That bump also drops `deployAndBroadcast`'s trailing dependency code-hash mapping, which no longer exists upstream. --- .gas-snapshot | 23 ++- CLAUDE.md | 33 +++- README.md | 44 ++++-- foundry.toml | 2 +- script/BuildPointers.sol | 2 +- script/Deploy.sol | 7 +- src/concrete/AddressRegistry.sol | 65 ++++++++ .../concrete/AddressRegistryDeployPins.t.sol | 40 +++++ test/src/concrete/AddressRegistryGet.t.sol | 75 +++++++++ .../concrete/AddressRegistryRegister.t.sol | 148 ++++++++++++++++++ test/src/lib/LibCloneFactoryDeploy.t.sol | 2 +- test/src/lib/LibCloneFactoryDeployProd.t.sol | 2 +- ...LibCloneFactoryDeployTaggedConstants.t.sol | 2 +- 13 files changed, 416 insertions(+), 29 deletions(-) create mode 100644 src/concrete/AddressRegistry.sol create mode 100644 test/src/concrete/AddressRegistryDeployPins.t.sol create mode 100644 test/src/concrete/AddressRegistryGet.t.sol create mode 100644 test/src/concrete/AddressRegistryRegister.t.sol diff --git a/.gas-snapshot b/.gas-snapshot index 59833c5..2472f50 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,9 +1,24 @@ -CloneFactoryCloneDeterministicTest:testCloneDeterministicEvent(bytes32,bytes) (runs: 2048, μ: 421457, ~: 371564) +AddressRegistryDeployPinsTest:testAddressRegistryDeploysToPinnedAddress() (gas: 185870) +AddressRegistryDeployPinsTest:testAddressRegistryPinsDeriveFromThisSource() (gas: 1430) +AddressRegistryGetTest:testGetNoGeneratedMappingGetter(bytes32) (runs: 2048, μ: 5460, ~: 5460) +AddressRegistryGetTest:testGetNoOtherEntryPoint(bytes4,bytes32) (runs: 2048, μ: 8746, ~: 8746) +AddressRegistryGetTest:testGetOpaqueNames(address) (runs: 2048, μ: 624044, ~: 624044) +AddressRegistryGetTest:testGetReturnsRegistered(bytes32,address) (runs: 2048, μ: 34917, ~: 34917) +AddressRegistryGetTest:testGetUnsetReverts(bytes32) (runs: 2048, μ: 10868, ~: 10868) +AddressRegistryRegisterTest:testRegisterDistinctNames(bytes32,bytes32,address,address) (runs: 2048, μ: 65290, ~: 65290) +AddressRegistryRegisterTest:testRegisterEvent(bytes32,address) (runs: 2048, μ: 36420, ~: 36420) +AddressRegistryRegisterTest:testRegisterNoEventOnRevert(address,bytes32,address) (runs: 2048, μ: 10763, ~: 10763) +AddressRegistryRegisterTest:testRegisterOnlyRoot(address,bytes32,address) (runs: 2048, μ: 9672, ~: 9672) +AddressRegistryRegisterTest:testRegisterOnlyRootWhenAlreadyBound(address,bytes32,address,address) (runs: 2048, μ: 36210, ~: 36210) +AddressRegistryRegisterTest:testRegisterWriteOnce(bytes32,address,address) (runs: 2048, μ: 36439, ~: 36439) +AddressRegistryRegisterTest:testRegisterWriteOnceSameAccount(bytes32,address) (runs: 2048, μ: 35973, ~: 35973) +AddressRegistryRegisterTest:testRegisterZeroAccount(bytes32) (runs: 2048, μ: 12750, ~: 12750) +CloneFactoryCloneDeterministicTest:testCloneDeterministicEvent(bytes32,bytes) (runs: 2048, μ: 420846, ~: 349101) CloneFactoryCloneDeterministicTest:testCloneDeterministicInitializeFailureFails(bytes32,bytes32) (runs: 2048, μ: 161266, ~: 161266) -CloneFactoryCloneDeterministicTest:testCloneDeterministicManyClonesPerImpl(bytes32,bytes32,bytes) (runs: 2048, μ: 569724, ~: 482275) -CloneFactoryCloneDeterministicTest:testCloneDeterministicMatchesPredict(bytes32,bytes) (runs: 2048, μ: 423089, ~: 372826) +CloneFactoryCloneDeterministicTest:testCloneDeterministicManyClonesPerImpl(bytes32,bytes32,bytes) (runs: 2048, μ: 571796, ~: 437419) +CloneFactoryCloneDeterministicTest:testCloneDeterministicMatchesPredict(bytes32,bytes) (runs: 2048, μ: 422475, ~: 350218) CloneFactoryCloneDeterministicTest:testCloneDeterministicSaltIsAbiEncodeHash(address,bytes32,address) (runs: 2048, μ: 4686, ~: 4686) -CloneFactoryCloneDeterministicTest:testCloneDeterministicSenderScoped(bytes32,bytes,address,address) (runs: 2048, μ: 585442, ~: 485815) +CloneFactoryCloneDeterministicTest:testCloneDeterministicSenderScoped(bytes32,bytes,address,address) (runs: 2048, μ: 590582, ~: 440959) CloneFactoryCloneDeterministicTest:testCloneDeterministicZeroImplementationCodeSize(address,bytes,bytes32) (runs: 2048, μ: 10699, ~: 10678) LibCloneFactoryDeployTaggedConstantsTest:testCloneFactory_0_1_3_CreationDeploysToPinnedAddress() (gas: 240145) LibCloneFactoryDeployTaggedConstantsTest:testCloneFactory_0_1_3_RuntimeHashesToBytecodeHash() (gas: 755) diff --git a/CLAUDE.md b/CLAUDE.md index 4c277ed..66ab95e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,20 @@ code in this repository. ## Project Overview -rain.factory.deploy is the **deployment** half of `rain.factory`: the concrete -`CloneFactory` contract plus its deployed address + codehash pins. The core -contract `CloneFactory` clones any contract implementing `ICloneableV2` (an -interface it imports from the `rain-factory` Soldeer package) and atomically -initializes it. +rain.factory.deploy holds Rain's Zoltu-deployed concrete contracts and their +deploy pins. Two unrelated contracts live here, sharing only that deploy model: + +- `CloneFactory` — the **deployment** half of `rain.factory`: the concrete + contract plus its deployed address + codehash pins. It clones any contract + implementing `ICloneableV2` (an interface it imports from the `rain-factory` + Soldeer package) and atomically initializes it. +- `AddressRegistry` — the implementation of `IAddressRegistryV1`, which arrives + from the `rain-deploy` Soldeer package alongside `LibAddressRegistry` (the + reader) and the cross-network deploy gate. An immutable root authority binds a + `bytes32` name to an address once and forever; reading an unbound name + reverts. It has no deploy pins yet — `ADDRESS_REGISTRY_ROOT` is a placeholder + and the root is part of the creation code, so no snapshot can exist until a + human supplies the real value. License: LicenseRef-DCL-1.0 (DecentraLicense). All source files must include SPDX headers. @@ -68,6 +77,14 @@ as the `rain-factory` Soldeer dependency, so they are read under - `src/concrete/CloneFactory.sol` — The single concrete implementation of `ICloneableFactoryV3`. Uses OpenZeppelin `Clones.cloneDeterministic()`; there is no plain `clone()`. +- `src/concrete/AddressRegistry.sol` — The single concrete implementation of + `IAddressRegistryV1` (from the `rain-deploy` Soldeer package). Two functions, + `register` and `get`, and nothing else: adding rotation, removal, an admin + surface or a non-reverting reader would destroy the write-once property the + contract exists for. `ADDRESS_REGISTRY_ROOT` is a placeholder until a human + supplies the real root; because it is a constant in the creation code, it is + part of the contract's identity, and `rain-deploy`'s `LibAddressRegistry` pins + must be re-derived whenever it changes. - `src/lib/LibCloneFactoryDeploy.sol` — Deterministic deployment address and codehash constants (generated; aliases the current tag's `src/generated//` snapshot). @@ -103,6 +120,12 @@ A deploy is a human-dispatched run of the `Manual sol artifacts` workflow (`workflow_dispatch` → `rainix-manual-sol-artifacts`), never a merge and never part of the release workflow. +`AddressRegistry` is not deployable yet and has no suite in `script/Deploy.sol`, +no `src/generated//` snapshot and no pin lib. Its root authority is a +placeholder, and the root is part of the creation code, so any snapshot +generated now would pin an address nobody can use. Supplying the real root is +what unblocks generating them. + ## Releases and versioning This is a **deploy repo**, not a library repo, so nothing publishes on merge: diff --git a/README.md b/README.md index 9e5cdbc..e8512b9 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,39 @@ # rain.factory.deploy -The **deployment** half of `rain.factory`: the concrete `CloneFactory` contract, -its deployed address + codehash pins (`LibCloneFactoryDeploy`), the frozen -per-tag deploy-pin snapshots under `src/generated//`, and the deploy -script. - -The **library** half — the `ICloneable*` interfaces — lives in -[`rain.factory`](https://github.com/rainlanguage/rain.factory) and is imported -here as the `rain-factory` Soldeer package. Consumers that need only the -interfaces depend on `rain-factory`; consumers that need the deployed -address/codehash pins depend on `rain-factory-deploy`. +Rain's Zoltu-deployed concrete contracts and their deploy pins. Two unrelated +contracts live here, sharing only that deploy model: + +- **`CloneFactory`** — the **deployment** half of `rain.factory`: the concrete + contract, its deployed address + codehash pins (`LibCloneFactoryDeploy`), the + frozen per-tag deploy-pin snapshots under `src/generated//`, and the + deploy script. The **library** half — the `ICloneable*` interfaces — lives in + [`rain.factory`](https://github.com/rainlanguage/rain.factory) and is imported + here as the `rain-factory` Soldeer package. Consumers that need only the + interfaces depend on `rain-factory`; consumers that need the deployed + address/codehash pins depend on `rain-factory-deploy`. +- **`AddressRegistry`** — the implementation of `IAddressRegistryV1`, which is + the same split: the interface, the library that reads a registered address, + and the cross-network deploy gate all live in + [`rain.deploy`](https://github.com/rainlanguage/rain.deploy) and arrive here + as the `rain-deploy` Soldeer package. + +## `AddressRegistry` + +An immutable root authority binds a `bytes32` name to an address, once, forever; +anyone reads a bound name; reading an unbound name reverts. There is no +rotation, no removal, no upgrade and no admin surface, because a binding that +can move is not worth checking at deploy time. + +Names are opaque. Nothing here says how one is derived, and nothing here should. + +`ADDRESS_REGISTRY_ROOT` in `src/concrete/AddressRegistry.sol` is currently a +**placeholder**. The root is a constant in the creation code, so it is part of +the contract's identity: changing it changes the deterministic deploy address +and code hash on every network. A human must supply the real root before this +contract is deployed anywhere or given a deploy-pin snapshot, and +`rain-deploy`'s `LibAddressRegistry` pins must be re-derived from the resulting +creation code at the same time. `AddressRegistryDeployPinsTest` fails if those +two ever disagree. ## Releases diff --git a/foundry.toml b/foundry.toml index ab1ebb2..48b6053 100644 --- a/foundry.toml +++ b/foundry.toml @@ -34,7 +34,7 @@ runs = 2048 forge-std = "1.16.1" "@openzeppelin-contracts" = "5.6.1" "rain-extrospection" = "0.1.1" -"rain-deploy" = "0.1.3" +"rain-deploy" = "0.1.6" "rain-sol-codegen" = "0.1.0" "rain-factory" = "0.1.5" diff --git a/script/BuildPointers.sol b/script/BuildPointers.sol index e108599..f9a0cd4 100644 --- a/script/BuildPointers.sol +++ b/script/BuildPointers.sol @@ -5,7 +5,7 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; import {LibCodeGen} from "rain-sol-codegen-0.1.0/src/lib/LibCodeGen.sol"; import {LibFs} from "rain-sol-codegen-0.1.0/src/lib/LibFs.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; import {CloneFactory} from "../src/concrete/CloneFactory.sol"; /// @title BuildPointers diff --git a/script/Deploy.sol b/script/Deploy.sol index eb7a3d4..9554f35 100644 --- a/script/Deploy.sol +++ b/script/Deploy.sol @@ -4,7 +4,7 @@ pragma solidity =0.8.25; import {Script} from "forge-std-1.16.1/src/Script.sol"; import {CloneFactory} from "../src/concrete/CloneFactory.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; import {LibCloneFactoryDeploy} from "../src/lib/LibCloneFactoryDeploy.sol"; /// @dev Hash of the "clone-factory" deployment suite string. @@ -13,8 +13,6 @@ bytes32 constant DEPLOYMENT_SUITE_CLONE_FACTORY = keccak256("clone-factory"); /// @title Deploy /// @notice A script that deploys a CloneFactory. contract Deploy is Script { - mapping(string => mapping(address => bytes32)) internal sDepCodeHashes; - function run() external { uint256 deployerPrivateKey = vm.envUint("DEPLOYMENT_KEY"); @@ -28,8 +26,7 @@ contract Deploy is Script { "src/concrete/CloneFactory.sol:CloneFactory", LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_ADDRESS, LibCloneFactoryDeploy.CLONE_FACTORY_DEPLOYED_CODEHASH, - new address[](0), - sDepCodeHashes + new address[](0) ); } else { revert( diff --git a/src/concrete/AddressRegistry.sol b/src/concrete/AddressRegistry.sol new file mode 100644 index 0000000..624e628 --- /dev/null +++ b/src/concrete/AddressRegistry.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IAddressRegistryV1} from "rain-deploy-0.1.6/src/interface/IAddressRegistryV1.sol"; + +/// @dev PLACEHOLDER ROOT AUTHORITY. THIS IS NOT A REAL ROOT. +/// +/// The only account that may bind a name. It is a compile-time constant, not +/// storage, so it can never be rotated, and it is part of the creation code, so +/// changing it changes the deterministic deploy address and code hash of +/// `AddressRegistry` on every network. +/// +/// A human MUST replace this value with the intended root before any deploy-pin +/// snapshot is generated for this contract, and the pins in `rain-deploy`'s +/// `LibAddressRegistry` MUST be regenerated from the resulting creation code. +address constant ADDRESS_REGISTRY_ROOT = address(0xdeaDDeADDEaDdeaDdEAddEADDEAdDeadDEADDEaD); + +/// @title AddressRegistry +/// @notice The whole of `IAddressRegistryV1`: an immutable root authority binds +/// a `bytes32` name that is unbound, anyone reads a name that is bound, and a +/// read of an unbound name reverts. +/// +/// There is deliberately nothing else. No rotation, no removal, no upgrade, no +/// pause, no admin surface, and no reader that returns the zero address for an +/// unbound name. Every one of those would turn a binding from a constant back +/// into a value that can move, which is the single property the registry +/// exists to provide. +/// +/// The storage mapping is `internal` rather than `public` for that reason: a +/// public mapping's generated getter answers an unbound name with the zero +/// address, which is exactly the silent failure `get` reverts to prevent. +contract AddressRegistry is IAddressRegistryV1 { + /// The bindings. Not `public`: the only reader is `get`, which reverts on an + /// unbound name. A name maps to the zero address if and only if it is + /// unbound, which is why `register` rejects the zero address. + mapping(bytes32 name => address account) internal sAddresses; + + /// @inheritdoc IAddressRegistryV1 + function register(bytes32 name, address account) external { + if (msg.sender != ADDRESS_REGISTRY_ROOT) { + revert NotRoot(msg.sender); + } + if (account == address(0)) { + revert ZeroAccount(name); + } + address registered = sAddresses[name]; + // Write-once. Root has no more authority here than anyone else: an + // existing binding is never overwritten, not even with the same + // address. + if (registered != address(0)) { + revert NameAlreadyRegistered(name, registered); + } + sAddresses[name] = account; + emit Register(name, account); + } + + /// @inheritdoc IAddressRegistryV1 + function get(bytes32 name) external view returns (address account) { + account = sAddresses[name]; + if (account == address(0)) { + revert NameNotRegistered(name); + } + } +} diff --git a/test/src/concrete/AddressRegistryDeployPins.t.sol b/test/src/concrete/AddressRegistryDeployPins.t.sol new file mode 100644 index 0000000..e1db2e3 --- /dev/null +++ b/test/src/concrete/AddressRegistryDeployPins.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; +import {LibAddressRegistry} from "rain-deploy-0.1.6/src/lib/LibAddressRegistry.sol"; +import {AddressRegistry} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryDeployPinsTest +/// @notice `rain-deploy`'s `LibAddressRegistry` pins the deterministic address +/// and code hash of the `AddressRegistry` in this repo, and consumers resolve +/// names through those pins. The root authority is a constant in this contract's +/// creation code, so changing it moves both pins; this suite is what makes that +/// loud instead of silent. +contract AddressRegistryDeployPinsTest is Test { + /// The pins MUST be derivable from this source without deploying anything: + /// the Zoltu factory is `CREATE2` over its calldata with a zero salt, so the + /// address is a pure function of the creation code, and the code hash is + /// `keccak256` of the runtime code that creation code leaves behind. + function testAddressRegistryPinsDeriveFromThisSource() external pure { + assertEq(LibRainDeploy.zoltuAddress(type(AddressRegistry).creationCode), LibAddressRegistry.ADDRESS_REGISTRY); + assertEq(keccak256(type(AddressRegistry).runtimeCode), LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + } + + /// Actually deploying this contract's creation code through the Zoltu + /// factory MUST land at the pinned address with the pinned code hash, so the + /// derivation is checked against the factory rather than only against + /// itself. + function testAddressRegistryDeploysToPinnedAddress() external { + LibRainDeploy.etchZoltuFactory(vm); + + address deployed = LibRainDeploy.deployZoltu(type(AddressRegistry).creationCode); + + assertEq(deployed, LibAddressRegistry.ADDRESS_REGISTRY); + assertEq(deployed.codehash, LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + assertEq(keccak256(deployed.code), LibAddressRegistry.ADDRESS_REGISTRY_CODEHASH); + } +} diff --git a/test/src/concrete/AddressRegistryGet.t.sol b/test/src/concrete/AddressRegistryGet.t.sol new file mode 100644 index 0000000..73848aa --- /dev/null +++ b/test/src/concrete/AddressRegistryGet.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; + +import {IAddressRegistryV1} from "rain-deploy-0.1.6/src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryGetTest +/// @notice A test suite for `AddressRegistry.get`: it answers a bound name with +/// its address and an unbound name with a revert, and it is the only reader. +contract AddressRegistryGetTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// A read of an unbound name reverts rather than returning the zero + /// address, so a caller cannot proceed on a name nobody bound by forgetting + /// to check. + function testGetUnsetReverts(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// A read of a bound name returns exactly what was bound, and reading does + /// not consume or alter the binding. + function testGetReturnsRegistered(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + assertEq(sRegistry.get(name), account); + } + + /// Names are opaque: nothing about a name's bytes changes how it is stored + /// or read, including names a string-hashing convention would never + /// produce. + function testGetOpaqueNames(address account) external { + vm.assume(account != address(0)); + + bytes32[3] memory names = [bytes32(0), bytes32(uint256(1)), bytes32(type(uint256).max)]; + for (uint256 i = 0; i < names.length; i++) { + AddressRegistry registry = new AddressRegistry(); + vm.prank(ADDRESS_REGISTRY_ROOT); + registry.register(names[i], account); + assertEq(registry.get(names[i]), account); + } + } + + /// `get` is the only reader. The bindings mapping is not `public`, so the + /// getter a `public` mapping would generate — which answers an unbound name + /// with the zero address, the exact silent failure `get` reverts to prevent + /// — does not exist. + function testGetNoGeneratedMappingGetter(bytes32 name) external { + (bool success,) = address(sRegistry).call(abi.encodeWithSignature("sAddresses(bytes32)", name)); + assertFalse(success); + } + + /// There is no other entry point at all: no fallback, no receive, and + /// nothing beyond the two `IAddressRegistryV1` functions, so an unknown + /// selector reverts instead of being silently absorbed. + function testGetNoOtherEntryPoint(bytes4 selector, bytes32 name) external { + vm.assume(selector != IAddressRegistryV1.get.selector); + vm.assume(selector != IAddressRegistryV1.register.selector); + + (bool success,) = address(sRegistry).call(abi.encodeWithSelector(selector, name, address(this))); + assertFalse(success); + } +} diff --git a/test/src/concrete/AddressRegistryRegister.t.sol b/test/src/concrete/AddressRegistryRegister.t.sol new file mode 100644 index 0000000..7ecdd1d --- /dev/null +++ b/test/src/concrete/AddressRegistryRegister.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; + +import {IAddressRegistryV1} from "rain-deploy-0.1.6/src/interface/IAddressRegistryV1.sol"; +import {AddressRegistry, ADDRESS_REGISTRY_ROOT} from "../../../src/concrete/AddressRegistry.sol"; + +/// @title AddressRegistryRegisterTest +/// @notice A test suite for `AddressRegistry.register`: who may bind a name, +/// and the write-once property that makes every binding a constant. +contract AddressRegistryRegisterTest is Test { + /// The registry under test. Stateful, so a fresh one per test. + AddressRegistry internal sRegistry; + + function setUp() external { + sRegistry = new AddressRegistry(); + } + + /// Only root may bind a name. Checked before the zero-address check, so a + /// non-root caller is rejected as `NotRoot` whatever it passes. + function testRegisterOnlyRoot(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + } + + /// A non-root caller cannot bind a name that is already bound either: the + /// authority check precedes the write-once check, and neither path lets a + /// binding move. + function testRegisterOnlyRootWhenAlreadyBound(address sender, bytes32 name, address bound, address account) + external + { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + vm.assume(bound != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), bound); + } + + /// A bound name can never be rebound, including by root. The binding that + /// is already there survives. + function testRegisterWriteOnce(bytes32 name, address bound, address account) external { + vm.assume(bound != address(0)); + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, bound); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameAlreadyRegistered.selector, name, bound)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), bound); + } + + /// Rebinding a name to the address it is already bound to is rejected too. + /// Write-once is a property of the name, not of the value changing, so a + /// no-op rebind is not a special case that slips through. + function testRegisterWriteOnceSameAccount(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameAlreadyRegistered.selector, name, account)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + + assertEq(sRegistry.get(name), account); + } + + /// The zero address is rejected. An unbound name reads as the zero address + /// internally, so binding it would produce a name that is bound but + /// unreadable — and that `register` would happily accept a second time, + /// destroying write-once. + function testRegisterZeroAccount(bytes32 name) external { + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.ZeroAccount.selector, name)); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, address(0)); + + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, name)); + sRegistry.get(name); + } + + /// Names are independent: binding one says nothing about any other, and + /// each remains bindable exactly once. + function testRegisterDistinctNames(bytes32 nameA, bytes32 nameB, address accountA, address accountB) external { + vm.assume(nameA != nameB); + vm.assume(accountA != address(0)); + vm.assume(accountB != address(0)); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameA, accountA); + + // Binding `nameA` did not bind `nameB`. + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NameNotRegistered.selector, nameB)); + sRegistry.get(nameB); + + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(nameB, accountB); + + assertEq(sRegistry.get(nameA), accountA); + assertEq(sRegistry.get(nameB), accountB); + } + + /// `Register` is emitted exactly once, with the name and account both + /// indexed so the log can be filtered by either. The log is the only + /// enumeration of the registry, so a binding that does not emit is a + /// binding nobody can find. + function testRegisterEvent(bytes32 name, address account) external { + vm.assume(account != address(0)); + + vm.recordLogs(); + vm.prank(ADDRESS_REGISTRY_ROOT); + sRegistry.register(name, account); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + assertEq(entries.length, 1); + assertEq(entries[0].emitter, address(sRegistry)); + assertEq(entries[0].topics.length, 3); + assertEq(entries[0].topics[0], keccak256("Register(bytes32,address)")); + assertEq(entries[0].topics[1], name); + assertEq(entries[0].topics[2], bytes32(uint256(uint160(account)))); + assertEq(entries[0].data.length, 0); + } + + /// A rejected `register` emits nothing, so a failed bind can never be + /// mistaken for a binding by anything reading the logs. + function testRegisterNoEventOnRevert(address sender, bytes32 name, address account) external { + vm.assume(sender != ADDRESS_REGISTRY_ROOT); + + vm.recordLogs(); + vm.expectRevert(abi.encodeWithSelector(IAddressRegistryV1.NotRoot.selector, sender)); + vm.prank(sender); + sRegistry.register(name, account); + assertEq(vm.getRecordedLogs().length, 0); + } +} diff --git a/test/src/lib/LibCloneFactoryDeploy.t.sol b/test/src/lib/LibCloneFactoryDeploy.t.sol index c319fec..40444ca 100644 --- a/test/src/lib/LibCloneFactoryDeploy.t.sol +++ b/test/src/lib/LibCloneFactoryDeploy.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; import {LibCloneFactoryDeploy} from "../../../src/lib/LibCloneFactoryDeploy.sol"; import {CloneFactory} from "../../../src/concrete/CloneFactory.sol"; diff --git a/test/src/lib/LibCloneFactoryDeployProd.t.sol b/test/src/lib/LibCloneFactoryDeployProd.t.sol index ac4c5ba..88daa95 100644 --- a/test/src/lib/LibCloneFactoryDeployProd.t.sol +++ b/test/src/lib/LibCloneFactoryDeployProd.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; import {LibCloneFactoryDeploy} from "../../../src/lib/LibCloneFactoryDeploy.sol"; /// @title LibCloneFactoryDeployProdTest diff --git a/test/src/lib/LibCloneFactoryDeployTaggedConstants.t.sol b/test/src/lib/LibCloneFactoryDeployTaggedConstants.t.sol index a13cb31..f1f4d15 100644 --- a/test/src/lib/LibCloneFactoryDeployTaggedConstants.t.sol +++ b/test/src/lib/LibCloneFactoryDeployTaggedConstants.t.sol @@ -3,7 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {LibRainDeploy} from "rain-deploy-0.1.6/src/lib/LibRainDeploy.sol"; import { BYTECODE_HASH as CLONE_FACTORY_BYTECODE_HASH_0_1_3, DEPLOYED_ADDRESS as CLONE_FACTORY_DEPLOYED_ADDRESS_0_1_3, From 1f4cc0ab655ce237cf36ae0c1c2450beb6b32ac9 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 8 Aug 2026 14:20:08 +0000 Subject: [PATCH 2/2] docs: AddressRegistry has pins, they are just not a snapshot in this repo Said it had 'no deploy pins' and 'no pin lib', which is wrong: rain-deploy's LibAddressRegistry pins its address and code hash, and AddressRegistryDeployPins tests them here. What it has none of is a src/generated// snapshot and a Deploy.sol suite, and the reason those must wait is that the directory is append-only, so a placeholder-derived snapshot could never be corrected. --- CLAUDE.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 66ab95e..4984a64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,9 +16,11 @@ deploy pins. Two unrelated contracts live here, sharing only that deploy model: from the `rain-deploy` Soldeer package alongside `LibAddressRegistry` (the reader) and the cross-network deploy gate. An immutable root authority binds a `bytes32` name to an address once and forever; reading an unbound name - reverts. It has no deploy pins yet — `ADDRESS_REGISTRY_ROOT` is a placeholder - and the root is part of the creation code, so no snapshot can exist until a - human supplies the real value. + reverts. Its address and code hash are pinned in `rain-deploy`'s + `LibAddressRegistry`, not in a `src/generated//` snapshot here — those + pins come from the current placeholder build, because `ADDRESS_REGISTRY_ROOT` + is a constant in the creation code and no snapshot should be frozen until a + human supplies the real root. License: LicenseRef-DCL-1.0 (DecentraLicense). All source files must include SPDX headers. @@ -120,11 +122,17 @@ A deploy is a human-dispatched run of the `Manual sol artifacts` workflow (`workflow_dispatch` → `rainix-manual-sol-artifacts`), never a merge and never part of the release workflow. -`AddressRegistry` is not deployable yet and has no suite in `script/Deploy.sol`, -no `src/generated//` snapshot and no pin lib. Its root authority is a -placeholder, and the root is part of the creation code, so any snapshot -generated now would pin an address nobody can use. Supplying the real root is -what unblocks generating them. +`AddressRegistry` is not deployable yet: it has no suite in `script/Deploy.sol` +and no `src/generated//` snapshot. Its root authority is a placeholder, and +the root is part of the creation code, so a snapshot frozen now would pin an +address nobody can use — and `src/generated//` is append-only, so it could +not be corrected afterwards. + +Its address and code hash are pinned in `rain-deploy`'s `LibAddressRegistry`, +derived from the same placeholder build. +`test/src/concrete/AddressRegistryDeployPins.t.sol` fails if this source and +those pins disagree, so supplying the real root means re-deriving them in the +same change. ## Releases and versioning