diff --git a/script/DeployHub.s.sol b/script/DeployHub.s.sol index 9022215..3a9d4ac 100644 --- a/script/DeployHub.s.sol +++ b/script/DeployHub.s.sol @@ -3,12 +3,14 @@ pragma solidity ^0.8.20; import "forge-std/Script.sol"; import "forge-std/console.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import {PoaManager} from "../src/PoaManager.sol"; import {PoaManagerHub} from "../src/crosschain/PoaManagerHub.sol"; /** * @title DeployHub - * @notice Deploys PoaManagerHub on the home chain and transfers PoaManager ownership to it. + * @notice Deploys PoaManagerHub on the home chain via BeaconProxy and transfers + * PoaManager ownership to it. * @dev Requires existing infrastructure (PoaManager) and a Hyperlane Mailbox address. * * Usage: @@ -21,20 +23,28 @@ contract DeployHub is Script { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); address poaManagerAddr = vm.envAddress("POAMANAGER"); address mailboxAddr = vm.envAddress("MAILBOX"); + address deployer = vm.addr(deployerKey); console.log("\n=== Deploying PoaManagerHub ==="); - console.log("Deployer:", vm.addr(deployerKey)); + console.log("Deployer:", deployer); console.log("PoaManager:", poaManagerAddr); console.log("Mailbox:", mailboxAddr); vm.startBroadcast(deployerKey); - // 1. Deploy Hub - PoaManagerHub hub = new PoaManagerHub(poaManagerAddr, mailboxAddr); + // 1. Deploy PoaManagerHub implementation and register type + PoaManager pm = PoaManager(poaManagerAddr); + address hubImpl = address(new PoaManagerHub()); + pm.addContractType("PoaManagerHub", hubImpl); + + // 2. Deploy PoaManagerHub behind BeaconProxy + address hubBeacon = pm.getBeaconById(keccak256("PoaManagerHub")); + bytes memory hubInit = abi.encodeCall(PoaManagerHub.initialize, (deployer, poaManagerAddr, mailboxAddr)); + PoaManagerHub hub = PoaManagerHub(payable(address(new BeaconProxy(hubBeacon, hubInit)))); console.log("PoaManagerHub deployed:", address(hub)); - // 2. Transfer PoaManager ownership to Hub - PoaManager(poaManagerAddr).transferOwnership(address(hub)); + // 3. Transfer PoaManager ownership to Hub + pm.transferOwnership(address(hub)); console.log("PoaManager ownership transferred to Hub"); vm.stopBroadcast(); diff --git a/script/DeployInfrastructure.s.sol b/script/DeployInfrastructure.s.sol index 1a77a8c..b0f29f6 100644 --- a/script/DeployInfrastructure.s.sol +++ b/script/DeployInfrastructure.s.sol @@ -27,6 +27,9 @@ import {OrgRegistry} from "../src/OrgRegistry.sol"; import {OrgDeployer} from "../src/OrgDeployer.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; +// Cross-chain (optional) +import {NameRegistryHub} from "../src/crosschain/NameRegistryHub.sol"; + // Factories import {GovernanceFactory} from "../src/factories/GovernanceFactory.sol"; import {AccessFactory} from "../src/factories/AccessFactory.sol"; @@ -72,6 +75,7 @@ contract DeployInfrastructure is Script { address public implRegistry; address public paymasterHub; address public universalPasskeyFactory; + address public nameRegistryHub; // Factories address public governanceFactory; @@ -263,6 +267,24 @@ contract DeployInfrastructure is Script { orgDeployer, orgRegistry, implRegistry, paymasterHub, globalAccountRegistry, universalPasskeyFactory ); console.log("\n--- Infrastructure Registered (for subgraph indexing) ---"); + + // Optional: Deploy NameRegistryHub if MAILBOX is provided + address mailboxAddr = vm.envOr("MAILBOX", address(0)); + if (mailboxAddr != address(0)) { + address nameHubImpl = address(new NameRegistryHub()); + pm.addContractType("NameRegistryHub", nameHubImpl); + address nameHubBeacon = pm.getBeaconById(keccak256("NameRegistryHub")); + bytes memory nameHubInit = + abi.encodeCall(NameRegistryHub.initialize, (deployer, globalAccountRegistry, mailboxAddr)); + NameRegistryHub hub = NameRegistryHub(payable(address(new BeaconProxy(nameHubBeacon, nameHubInit)))); + UniversalAccountRegistry(globalAccountRegistry).setNameRegistryHub(address(hub)); + nameRegistryHub = address(hub); + console.log("\n--- Cross-Chain Name Registry ---"); + console.log("NameRegistryHub:", nameRegistryHub); + console.log("GlobalAccountRegistry wired to NameRegistryHub"); + } else { + console.log("\n--- Cross-Chain Name Registry: SKIPPED (no MAILBOX env var) ---"); + } } function _outputAddresses() internal { @@ -352,6 +374,9 @@ contract DeployInfrastructure is Script { '",\n', ' "passkeyAccountFactoryBeacon": "', vm.toString(pm.getBeaconById(keccak256("PasskeyAccountFactory"))), + '",\n', + ' "nameRegistryHub": "', + vm.toString(nameRegistryHub), '"\n', "}\n" ); diff --git a/script/DeployNameRegistryHub.s.sol b/script/DeployNameRegistryHub.s.sol new file mode 100644 index 0000000..ba376e3 --- /dev/null +++ b/script/DeployNameRegistryHub.s.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {NameRegistryHub} from "../src/crosschain/NameRegistryHub.sol"; +import {UniversalAccountRegistry} from "../src/UniversalAccountRegistry.sol"; +import {PoaManager} from "../src/PoaManager.sol"; + +/** + * @title DeployNameRegistryHub + * @notice Deploys NameRegistryHub on Arbitrum (home chain) via BeaconProxy and wires + * it to the existing UniversalAccountRegistry. + * + * Usage: + * UAR=0x... MAILBOX=0x... POA_MANAGER=0x... \ + * forge script script/DeployNameRegistryHub.s.sol:DeployNameRegistryHub \ + * --rpc-url $RPC_URL --broadcast --verify + */ +contract DeployNameRegistryHub is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address uarAddr = vm.envAddress("UAR"); + address mailboxAddr = vm.envAddress("MAILBOX"); + address poaManagerAddr = vm.envAddress("POA_MANAGER"); + address deployer = vm.addr(deployerKey); + + console.log("\n=== Deploying NameRegistryHub ==="); + console.log("Deployer:", deployer); + console.log("UAR:", uarAddr); + console.log("Mailbox:", mailboxAddr); + console.log("PoaManager:", poaManagerAddr); + + vm.startBroadcast(deployerKey); + + // 1. Deploy NameRegistryHub implementation and register type + PoaManager pm = PoaManager(poaManagerAddr); + address nameHubImpl = address(new NameRegistryHub()); + pm.addContractType("NameRegistryHub", nameHubImpl); + + // 2. Deploy NameRegistryHub behind BeaconProxy + address nameHubBeacon = pm.getBeaconById(keccak256("NameRegistryHub")); + bytes memory nameHubInit = abi.encodeCall(NameRegistryHub.initialize, (deployer, uarAddr, mailboxAddr)); + NameRegistryHub hub = NameRegistryHub(payable(address(new BeaconProxy(nameHubBeacon, nameHubInit)))); + console.log("NameRegistryHub deployed:", address(hub)); + + // 3. Wire UAR to hub (enables global uniqueness checks) + UniversalAccountRegistry(uarAddr).setNameRegistryHub(address(hub)); + console.log("UAR.nameRegistryHub set to hub"); + + vm.stopBroadcast(); + + console.log("\n=== NameRegistryHub Deployment Complete ==="); + console.log("Hub address:", address(hub)); + console.log("\nNext steps:"); + console.log(" 1. Fund hub with ETH for return-trip Hyperlane fees"); + console.log(" 2. Set return fee: hub.setReturnFee(fee)"); + console.log(" 3. Deploy RegistryRelay on satellite chains"); + console.log(" 4. Register relays: hub.registerSatellite(domain, relayAddr)"); + } +} diff --git a/script/DeployRegistryRelay.s.sol b/script/DeployRegistryRelay.s.sol new file mode 100644 index 0000000..d7b4842 --- /dev/null +++ b/script/DeployRegistryRelay.s.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {RegistryRelay} from "../src/crosschain/RegistryRelay.sol"; +import {PoaManager} from "../src/PoaManager.sol"; + +/** + * @title DeployRegistryRelay + * @notice Deploys a RegistryRelay on a satellite chain via BeaconProxy, pointed at + * the NameRegistryHub on Arbitrum. + * + * Usage: + * MAILBOX=0x... HUB_DOMAIN=42161 HUB_ADDRESS=0x... POA_MANAGER=0x... \ + * forge script script/DeployRegistryRelay.s.sol:DeployRegistryRelay \ + * --rpc-url $RPC_URL --broadcast --verify + */ +contract DeployRegistryRelay is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address mailboxAddr = vm.envAddress("MAILBOX"); + uint32 hubDomain = uint32(vm.envUint("HUB_DOMAIN")); + address hubAddress = vm.envAddress("HUB_ADDRESS"); + address poaManagerAddr = vm.envAddress("POA_MANAGER"); + address deployer = vm.addr(deployerKey); + + console.log("\n=== Deploying RegistryRelay ==="); + console.log("Deployer:", deployer); + console.log("Mailbox:", mailboxAddr); + console.log("Hub domain:", hubDomain); + console.log("Hub address:", hubAddress); + console.log("PoaManager:", poaManagerAddr); + + vm.startBroadcast(deployerKey); + + // 1. Deploy RegistryRelay implementation and register type + PoaManager pm = PoaManager(poaManagerAddr); + address relayImpl = address(new RegistryRelay()); + pm.addContractType("RegistryRelay", relayImpl); + + // 2. Deploy RegistryRelay behind BeaconProxy + address relayBeacon = pm.getBeaconById(keccak256("RegistryRelay")); + bytes memory relayInit = + abi.encodeCall(RegistryRelay.initialize, (deployer, mailboxAddr, hubDomain, hubAddress)); + RegistryRelay relay = RegistryRelay(payable(address(new BeaconProxy(relayBeacon, relayInit)))); + console.log("RegistryRelay deployed:", address(relay)); + + vm.stopBroadcast(); + + console.log("\n=== RegistryRelay Deployment Complete ==="); + console.log("Relay address:", address(relay)); + console.log("\nNext steps:"); + console.log(" 1. Register on hub: hub.registerSatellite(,", address(relay), ")"); + } +} diff --git a/script/DeploySatelliteInfrastructure.s.sol b/script/DeploySatelliteInfrastructure.s.sol index 9734aa1..f2bf71a 100644 --- a/script/DeploySatelliteInfrastructure.s.sol +++ b/script/DeploySatelliteInfrastructure.s.sol @@ -75,8 +75,13 @@ contract DeploySatelliteInfrastructure is Script { // 5. Register all contract types (using deterministic addresses) _registerContractTypes(pm, dd); - // 6. Deploy PoaManagerSatellite - PoaManagerSatellite satellite = new PoaManagerSatellite(address(pm), mailboxAddr, hubDomain, hubAddress); + // 6. Deploy PoaManagerSatellite via BeaconProxy + address satImpl = address(new PoaManagerSatellite()); + pm.addContractType("PoaManagerSatellite", satImpl); + address satBeacon = pm.getBeaconById(keccak256("PoaManagerSatellite")); + bytes memory satInit = + abi.encodeCall(PoaManagerSatellite.initialize, (deployer, address(pm), mailboxAddr, hubDomain, hubAddress)); + PoaManagerSatellite satellite = PoaManagerSatellite(payable(address(new BeaconProxy(satBeacon, satInit)))); // 7. Transfer PoaManager ownership to Satellite pm.transferOwnership(address(satellite)); diff --git a/script/MainDeploy.s.sol b/script/MainDeploy.s.sol index b7431c9..8f90b57 100644 --- a/script/MainDeploy.s.sol +++ b/script/MainDeploy.s.sol @@ -26,6 +26,10 @@ import {HatsTreeSetup} from "../src/HatsTreeSetup.sol"; import {DeterministicDeployer} from "../src/crosschain/DeterministicDeployer.sol"; import {PoaManagerHub} from "../src/crosschain/PoaManagerHub.sol"; import {PoaManagerSatellite} from "../src/crosschain/PoaManagerSatellite.sol"; +import {NameRegistryHub} from "../src/crosschain/NameRegistryHub.sol"; +import {RegistryRelay} from "../src/crosschain/RegistryRelay.sol"; +import {NameClaimAdapter} from "../src/crosschain/NameClaimAdapter.sol"; +import {SatelliteOnboardingHelper} from "../src/crosschain/SatelliteOnboardingHelper.sol"; // Config structs import {IHybridVotingInit} from "../src/libs/ModuleDeploymentLib.sol"; @@ -81,6 +85,7 @@ contract DeployHomeChain is DeployHelper { address accessFactory; address modulesFactory; address hatsTreeSetup; + address nameRegistryHub; } /*═══════════════════════════ MAIN ═══════════════════════════*/ @@ -108,10 +113,32 @@ contract DeployHomeChain is DeployHelper { // 2. Deploy full infrastructure InfraResult memory infra = _deployInfrastructure(deployer); - // 3. Deploy PoaManagerHub and transfer PoaManager ownership to it - PoaManagerHub hub = new PoaManagerHub(infra.poaManager, mailboxAddr); - PoaManager(infra.poaManager).transferOwnership(address(hub)); + // 3. Deploy PoaManagerHub via BeaconProxy + PoaManager pm = PoaManager(infra.poaManager); + address poaHubImpl = address(new PoaManagerHub()); + pm.addContractType("PoaManagerHub", poaHubImpl); + address poaHubBeacon = pm.getBeaconById(keccak256("PoaManagerHub")); + bytes memory poaHubInit = abi.encodeCall(PoaManagerHub.initialize, (deployer, infra.poaManager, mailboxAddr)); + PoaManagerHub hub = PoaManagerHub(payable(address(new BeaconProxy(poaHubBeacon, poaHubInit)))); console.log("PoaManagerHub:", address(hub)); + + // 3b. Deploy NameRegistryHub via BeaconProxy and wire to GlobalAccountRegistry + OrgRegistry + address nameHubImpl = address(new NameRegistryHub()); + pm.addContractType("NameRegistryHub", nameHubImpl); + address nameHubBeacon = pm.getBeaconById(keccak256("NameRegistryHub")); + bytes memory nameHubInit = + abi.encodeCall(NameRegistryHub.initialize, (deployer, infra.globalAccountRegistry, mailboxAddr)); + NameRegistryHub nameHub = NameRegistryHub(payable(address(new BeaconProxy(nameHubBeacon, nameHubInit)))); + UniversalAccountRegistry(infra.globalAccountRegistry).setNameRegistryHub(address(nameHub)); + nameHub.setAuthorizedOrgRegistry(infra.orgRegistry, true); + OrgRegistry(infra.orgRegistry).setNameRegistryHub(address(nameHub)); + infra.nameRegistryHub = address(nameHub); + console.log("NameRegistryHub:", address(nameHub)); + console.log("GlobalAccountRegistry wired to NameRegistryHub"); + console.log("OrgRegistry wired to NameRegistryHub"); + + // 3c. Transfer PoaManager ownership to Hub (after both types are registered) + pm.transferOwnership(address(hub)); console.log("PoaManager ownership transferred to Hub"); // 4. Deploy governance org @@ -435,6 +462,7 @@ contract DeployHomeChain is DeployHelper { vm.serializeAddress(home, "accessFactory", infra.accessFactory); vm.serializeAddress(home, "modulesFactory", infra.modulesFactory); vm.serializeAddress(home, "hatsTreeSetup", infra.hatsTreeSetup); + vm.serializeAddress(home, "nameRegistryHub", infra.nameRegistryHub); vm.serializeAddress(home, "hub", hub); string memory homeJson = vm.serializeString(home, "governance", govJson); @@ -470,6 +498,10 @@ contract DeployHomeChain is DeployHelper { * --rpc-url $SATELLITE_RPC --broadcast --slow */ contract DeploySatellite is DeployHelper { + address public constant HATS_PROTOCOL = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; + address public constant ENTRY_POINT_V07 = 0x0000000071727De22E5E9d8BAf0edAc6f37da032; + address public constant POA_GUARDIAN = address(0); + uint256 public constant INITIAL_SOLIDARITY_FUND = 0.1 ether; bytes32 public constant DD_SALT = keccak256("POA_DETERMINISTIC_DEPLOYER_V1"); function run() public { @@ -507,12 +539,113 @@ contract DeploySatellite is DeployHelper { reg.transferOwnership(address(pm)); // 3. Deploy implementations via DD and register all contract types + // (includes NameClaimAdapter + SatelliteOnboardingHelper) _deployAndRegisterTypesDD(pm, dd); - // 4. Deploy PoaManagerSatellite - PoaManagerSatellite satellite = new PoaManagerSatellite(address(pm), mailboxAddr, hubDomain, hubAddress); + // 4. Deploy PoaManagerSatellite via BeaconProxy + address satImpl = address(new PoaManagerSatellite()); + pm.addContractType("PoaManagerSatellite", satImpl); + address satBeacon = pm.getBeaconById(keccak256("PoaManagerSatellite")); + bytes memory satInit = + abi.encodeCall(PoaManagerSatellite.initialize, (deployer, address(pm), mailboxAddr, hubDomain, hubAddress)); + PoaManagerSatellite satellite = PoaManagerSatellite(payable(address(new BeaconProxy(satBeacon, satInit)))); + + // 4b. Deploy RegistryRelay via BeaconProxy (points to NameRegistryHub on home chain) + address nameHubAddress = vm.parseJsonAddress(state, ".homeChain.nameRegistryHub"); + address relayImpl = address(new RegistryRelay()); + pm.addContractType("RegistryRelay", relayImpl); + address relayBeacon = pm.getBeaconById(keccak256("RegistryRelay")); + bytes memory relayInit = + abi.encodeCall(RegistryRelay.initialize, (deployer, mailboxAddr, hubDomain, nameHubAddress)); + RegistryRelay registryRelay = RegistryRelay(payable(address(new BeaconProxy(relayBeacon, relayInit)))); + console.log("RegistryRelay:", address(registryRelay)); + + // 5. Deploy satellite org infrastructure + // NameClaimAdapter bridges OrgRegistry's sync interface to RegistryRelay's async cache + address adapterBeacon = pm.getBeaconById(keccak256("NameClaimAdapter")); + bytes memory adapterInit = abi.encodeCall(NameClaimAdapter.initialize, (deployer, address(registryRelay))); + NameClaimAdapter nameClaimAdapter = NameClaimAdapter(address(new BeaconProxy(adapterBeacon, adapterInit))); + console.log("NameClaimAdapter:", address(nameClaimAdapter)); + + // 5b. Deploy OrgRegistry + OrgDeployer for satellite org creation + address orgRegBeacon = pm.getBeaconById(keccak256("OrgRegistry")); + bytes memory orgRegInit = abi.encodeWithSignature("initialize(address,address)", deployer, HATS_PROTOCOL); + OrgRegistry satOrgRegistry = OrgRegistry(address(new BeaconProxy(orgRegBeacon, orgRegInit))); + + // Wire OrgRegistry to NameClaimAdapter (instead of NameRegistryHub) + satOrgRegistry.setNameRegistryHub(address(nameClaimAdapter)); + nameClaimAdapter.setAuthorizedCaller(address(satOrgRegistry), true); + console.log("OrgRegistry:", address(satOrgRegistry)); + + // 5c. Deploy factories (stateless) + address govFactory = address(new GovernanceFactory()); + address accFactory = address(new AccessFactory()); + address modFactory = address(new ModulesFactory()); + address hatsSetup = address(new HatsTreeSetup()); + console.log("Factories deployed"); + + // 5d. Deploy PaymasterHub for satellite + address paymasterHubImpl = address(new PaymasterHub()); + pm.addContractType("PaymasterHub", paymasterHubImpl); + address paymasterHubBeacon = pm.getBeaconById(keccak256("PaymasterHub")); + bytes memory paymasterHubInit = + abi.encodeWithSignature("initialize(address,address,address)", ENTRY_POINT_V07, HATS_PROTOCOL, address(pm)); + address satPaymasterHub = address(new BeaconProxy(paymasterHubBeacon, paymasterHubInit)); + PaymasterHub(payable(satPaymasterHub)).donateToSolidarity{value: INITIAL_SOLIDARITY_FUND}(); + console.log("PaymasterHub:", satPaymasterHub); + console.log("Solidarity Fund seeded with:", INITIAL_SOLIDARITY_FUND); + + // 5e. Deploy OrgDeployer + address deployerBeacon = pm.getBeaconById(keccak256("OrgDeployer")); + bytes memory orgDeployerInit = abi.encodeWithSignature( + "initialize(address,address,address,address,address,address,address,address)", + govFactory, + accFactory, + modFactory, + address(pm), + address(satOrgRegistry), + HATS_PROTOCOL, + hatsSetup, + satPaymasterHub + ); + address satOrgDeployer = address(new BeaconProxy(deployerBeacon, orgDeployerInit)); + console.log("OrgDeployer:", satOrgDeployer); + + // Transfer OrgRegistry ownership to OrgDeployer + satOrgRegistry.transferOwnership(satOrgDeployer); + + // Authorize OrgDeployer on PaymasterHub + PoaManager(address(pm)) + .adminCall(satPaymasterHub, abi.encodeWithSignature("setOrgRegistrar(address)", satOrgDeployer)); + + // Deploy universal PasskeyAccountFactory + address passkeyAccountBeacon = pm.getBeaconById(keccak256("PasskeyAccount")); + address passkeyFactoryBeaconAddr = pm.getBeaconById(keccak256("PasskeyAccountFactory")); + bytes memory passkeyFactoryInit = abi.encodeWithSignature( + "initialize(address,address,address,uint48)", + address(pm), + passkeyAccountBeacon, + POA_GUARDIAN, + uint48(7 days) + ); + address satPasskeyFactory = address(new BeaconProxy(passkeyFactoryBeaconAddr, passkeyFactoryInit)); + PoaManager(address(pm)) + .adminCall( + satOrgDeployer, abi.encodeWithSignature("setUniversalPasskeyFactory(address)", satPasskeyFactory) + ); + console.log("UniversalPasskeyFactory:", satPasskeyFactory); + + // Register infrastructure for subgraph indexing + pm.registerInfrastructure( + satOrgDeployer, + address(satOrgRegistry), + address(reg), + satPaymasterHub, + address(registryRelay), // RegistryRelay serves as the satellite's account registry + satPasskeyFactory + ); - // 5. Transfer PoaManager ownership to Satellite + // 6. Transfer PoaManager ownership to Satellite (after all types registered) pm.transferOwnership(address(satellite)); vm.stopBroadcast(); @@ -533,6 +666,11 @@ contract DeploySatellite is DeployHelper { string memory satObj = "satellite_state"; vm.serializeUint(satObj, "domain", uint256(satDomain)); vm.serializeAddress(satObj, "satellite", address(satellite)); + vm.serializeAddress(satObj, "registryRelay", address(registryRelay)); + vm.serializeAddress(satObj, "nameClaimAdapter", address(nameClaimAdapter)); + vm.serializeAddress(satObj, "orgRegistry", address(satOrgRegistry)); + vm.serializeAddress(satObj, "orgDeployer", satOrgDeployer); + vm.serializeAddress(satObj, "paymasterHub", satPaymasterHub); vm.serializeAddress(satObj, "poaManager", address(pm)); string memory satJson = vm.serializeAddress(satObj, "implRegistry", address(reg)); string memory filename = string.concat("script/satellite-state-", vm.toString(uint256(satDomain)), ".json"); @@ -587,19 +725,23 @@ contract RegisterAndTransfer is Script { // Read state string memory state = vm.readFile("script/main-deploy-state.json"); address hubAddr = vm.parseJsonAddress(state, ".homeChain.hub"); + address nameHubAddr = vm.parseJsonAddress(state, ".homeChain.nameRegistryHub"); address executorAddr = vm.parseJsonAddress(state, ".homeChain.governance.executor"); console.log("\n=== MainDeploy: Register Satellites & Transfer Ownership ==="); - console.log("Hub:", hubAddr); + console.log("PoaManagerHub:", hubAddr); + console.log("NameRegistryHub:", nameHubAddr); console.log("Executor:", executorAddr); console.log("Satellites to register:", numSatellites); vm.startBroadcast(deployerKey); PoaManagerHub hub = PoaManagerHub(payable(hubAddr)); + NameRegistryHub nameHub = NameRegistryHub(payable(nameHubAddr)); // Build set of already-registered active domains (for idempotent re-runs) - uint256 existingCount = hub.satelliteCount(); + uint256 existingHubCount = hub.satelliteCount(); + uint256 existingNameHubCount = nameHub.satelliteCount(); // Register each satellite by reading its state file for (uint256 i = 0; i < numSatellites; i++) { @@ -607,38 +749,64 @@ contract RegisterAndTransfer is Script { string memory envKey = string.concat("SATELLITE_DOMAIN_", vm.toString(i)); uint32 domain = uint32(vm.envUint(envKey)); - // Skip if this domain is already actively registered - bool alreadyRegistered = false; - for (uint256 j = 0; j < existingCount; j++) { - (uint32 existingDomain,, bool active) = hub.satellites(j); - if (existingDomain == domain && active) { - alreadyRegistered = true; - break; - } - } - if (alreadyRegistered) { - console.log("Satellite already registered, skipping domain:", domain); - continue; - } - - // Read satellite address from its state file + // Read satellite state file string memory filename = string.concat("script/satellite-state-", vm.toString(uint256(domain)), ".json"); string memory satState = vm.readFile(filename); - address satAddr = vm.parseJsonAddress(satState, ".satellite"); - hub.registerSatellite(domain, satAddr); - console.log("Registered satellite domain:", domain, "at", satAddr); + // Register PoaManagerSatellite (skip if already active) + bool hubRegistered = _isDomainRegistered(hub, domain, existingHubCount); + if (hubRegistered) { + console.log("PoaManagerSatellite already registered, skipping domain:", domain); + } else { + address satAddr = vm.parseJsonAddress(satState, ".satellite"); + hub.registerSatellite(domain, satAddr); + console.log("Registered PoaManagerSatellite domain:", domain, "at", satAddr); + } + + // Register RegistryRelay on NameRegistryHub (skip if already active) + bool relayRegistered = _isDomainRegisteredOnNameHub(nameHub, domain, existingNameHubCount); + if (relayRegistered) { + console.log("RegistryRelay already registered, skipping domain:", domain); + } else { + address relayAddr = vm.parseJsonAddress(satState, ".registryRelay"); + nameHub.registerSatellite(domain, relayAddr); + console.log("Registered RegistryRelay domain:", domain, "at", relayAddr); + } } // Transfer Hub ownership to Executor (governance now controls upgrades) hub.transferOwnership(executorAddr); - console.log("\nHub ownership transferred to Executor:", executorAddr); + console.log("\nPoaManagerHub ownership transferred to Executor:", executorAddr); + + nameHub.transferOwnership(executorAddr); + console.log("NameRegistryHub ownership transferred to Executor:", executorAddr); vm.stopBroadcast(); console.log("\n=== Registration & Transfer Complete ==="); console.log("Governance chain is now fully wired:"); console.log(" HybridVoting -> Executor -> Hub -> PoaManager"); + console.log(" HybridVoting -> Executor -> NameRegistryHub -> UAR"); + } + + function _isDomainRegistered(PoaManagerHub hub, uint32 domain, uint256 count) internal view returns (bool) { + for (uint256 j = 0; j < count; j++) { + (uint32 d,, bool active) = hub.satellites(j); + if (d == domain && active) return true; + } + return false; + } + + function _isDomainRegisteredOnNameHub(NameRegistryHub nameHub, uint32 domain, uint256 count) + internal + view + returns (bool) + { + for (uint256 j = 0; j < count; j++) { + (uint32 d,, bool active) = nameHub.satellites(j); + if (d == domain && active) return true; + } + return false; } } @@ -659,8 +827,10 @@ contract VerifyDeployment is Script { function run() public view { string memory state = vm.readFile("script/main-deploy-state.json"); address hubAddr = vm.parseJsonAddress(state, ".homeChain.hub"); + address nameHubAddr = vm.parseJsonAddress(state, ".homeChain.nameRegistryHub"); address executorAddr = vm.parseJsonAddress(state, ".homeChain.governance.executor"); address pmAddr = vm.parseJsonAddress(state, ".homeChain.poaManager"); + address garAddr = vm.parseJsonAddress(state, ".homeChain.globalAccountRegistry"); address orgDeployerAddr = vm.parseJsonAddress(state, ".homeChain.orgDeployer"); console.log("\n=== Deployment Verification (Home Chain) ==="); @@ -668,15 +838,31 @@ contract VerifyDeployment is Script { uint256 checks; uint256 passed; - // Check Hub owner + // Check PoaManagerHub owner address hubOwner = PoaManagerHub(payable(hubAddr)).owner(); - console.log("\nHub owner:", hubOwner); + console.log("\nPoaManagerHub owner:", hubOwner); console.log("Expected (Executor):", executorAddr); bool hubCheck = hubOwner == executorAddr; - console.log("Hub ownership:", hubCheck ? "PASS" : "FAIL"); + console.log("PoaManagerHub ownership:", hubCheck ? "PASS" : "FAIL"); checks++; if (hubCheck) passed++; + // Check NameRegistryHub owner + address nameHubOwner = NameRegistryHub(payable(nameHubAddr)).owner(); + console.log("\nNameRegistryHub owner:", nameHubOwner); + bool nameHubCheck = nameHubOwner == executorAddr; + console.log("NameRegistryHub ownership:", nameHubCheck ? "PASS" : "FAIL"); + checks++; + if (nameHubCheck) passed++; + + // Check UAR wired to NameRegistryHub + address wiredHub = UniversalAccountRegistry(garAddr).nameRegistryHub(); + console.log("\nUAR.nameRegistryHub:", wiredHub); + bool uarCheck = wiredHub == nameHubAddr; + console.log("UAR wired to NameRegistryHub:", uarCheck ? "PASS" : "FAIL"); + checks++; + if (uarCheck) passed++; + // Check PoaManager owner address pmOwner = PoaManager(pmAddr).owner(); console.log("\nPoaManager owner:", pmOwner); @@ -686,26 +872,31 @@ contract VerifyDeployment is Script { checks++; if (pmCheck) passed++; - // Check satellite count + // Check PoaManagerHub satellite count uint256 satCount = PoaManagerHub(payable(hubAddr)).satelliteCount(); - console.log("\nRegistered satellites:", satCount); + console.log("\nPoaManagerHub satellites:", satCount); bool satCheck = satCount > 0; console.log("Has satellites:", satCheck ? "PASS" : "WARNING - none registered"); checks++; if (satCheck) passed++; + // Check NameRegistryHub satellite count + uint256 nameSatCount = NameRegistryHub(payable(nameHubAddr)).satelliteCount(); + console.log("NameRegistryHub satellites:", nameSatCount); + bool nameSatCheck = nameSatCount > 0; + console.log("Has relays:", nameSatCheck ? "PASS" : "WARNING - none registered"); + checks++; + if (nameSatCheck) passed++; + // Check Executor ETH balance uint256 execBal = executorAddr.balance; - console.log("Executor ETH balance:", execBal); + console.log("\nExecutor ETH balance:", execBal); bool execCheck = execBal > 0; console.log("Has Hyperlane funds:", execCheck ? "PASS" : "WARNING - no ETH"); checks++; if (execCheck) passed++; - // Check OrgDeployer is set as orgRegistrar on PaymasterHub - address paymasterAddr = vm.parseJsonAddress(state, ".homeChain.paymasterHub"); - // Note: We can't directly read orgRegistrar from PaymasterHub (it's in private storage), - // but we verify OrgDeployer exists + // Check OrgDeployer exists bool deployerCheck = orgDeployerAddr.code.length > 0; console.log("\nOrgDeployer has code:", deployerCheck ? "PASS" : "FAIL"); checks++; diff --git a/script/RunOrgActions.s.sol b/script/RunOrgActions.s.sol index 782f8c1..d8f7017 100644 --- a/script/RunOrgActions.s.sol +++ b/script/RunOrgActions.s.sol @@ -16,6 +16,7 @@ import {OrgRegistry} from "../src/OrgRegistry.sol"; import {UniversalAccountRegistry} from "../src/UniversalAccountRegistry.sol"; import {RoleConfigStructs} from "../src/libs/RoleConfigStructs.sol"; import {ModulesFactory} from "../src/factories/ModulesFactory.sol"; +import {NameRegistryHub} from "../src/crosschain/NameRegistryHub.sol"; /** * @title RunOrgActions @@ -214,6 +215,9 @@ contract RunOrgActions is Script { // Step 5: Create and Execute Governance Proposal _demonstrateGovernance(); + // Step 6: Cross-Chain Name Registry + _demonstrateNameRegistry(); + console.log("\n========================================================"); console.log(" Demo Complete! All Actions Executed Successfully "); console.log("========================================================\n"); @@ -1023,4 +1027,60 @@ contract RunOrgActions is Script { return bootstrap; } + + /*=========================== STEP 6: NAME REGISTRY ===========================*/ + + function _demonstrateNameRegistry() internal { + console.log("\n======================================================="); + console.log("STEP 6: Cross-Chain Name Registry"); + console.log("=======================================================\n"); + + // Read infrastructure addresses + string memory infraJson = vm.readFile("script/infrastructure.json"); + address garAddr = vm.parseJsonAddress(infraJson, ".globalAccountRegistry"); + + // Check if nameRegistryHub was deployed (optional — depends on MAILBOX) + address nameHubAddr = vm.parseJsonAddress(infraJson, ".nameRegistryHub"); + if (nameHubAddr == address(0)) { + console.log(" NameRegistryHub not deployed (no MAILBOX during infra deploy)"); + console.log(" Skipping cross-chain name registry demo\n"); + return; + } + + console.log(" GlobalAccountRegistry:", garAddr); + console.log(" NameRegistryHub:", nameHubAddr); + + // Verify wiring + address wiredHub = UniversalAccountRegistry(garAddr).nameRegistryHub(); + require(wiredHub == nameHubAddr, "UAR not wired to NameRegistryHub"); + console.log(" UAR wired to NameRegistryHub: OK"); + + // Register a username (home-chain path — instant, no Hyperlane) + UniversalAccountRegistry gar = UniversalAccountRegistry(garAddr); + string memory existingUsername = gar.getUsername(members.deployer); + + if (bytes(existingUsername).length > 0) { + console.log(" Deployer already has username:", existingUsername); + console.log(" Skipping registration (already onboarded)"); + } else { + uint256 deployerKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + vm.startBroadcast(deployerKey); + gar.registerAccount("demo-user"); + vm.stopBroadcast(); + console.log(" Registered username: demo-user"); + } + + // Verify registration + string memory username = gar.getUsername(members.deployer); + console.log(" Current username:", username); + + // Verify global reservation on hub (check the deployer's actual username, + // which is already stored normalized/lowercase by UAR) + bytes32 nameHash = keccak256(bytes(username)); + bool isReserved = NameRegistryHub(payable(nameHubAddr)).reserved(nameHash); + require(isReserved, "Name not globally reserved on hub"); + console.log(" Name globally reserved: true"); + + console.log("\n[OK] Cross-Chain Name Registry Demo Complete\n"); + } } diff --git a/script/e2e/DeploySatelliteOrg.s.sol b/script/e2e/DeploySatelliteOrg.s.sol new file mode 100644 index 0000000..811a638 --- /dev/null +++ b/script/e2e/DeploySatelliteOrg.s.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; + +import {OrgDeployer, ITaskManagerBootstrap} from "../../src/OrgDeployer.sol"; +import {RegistryRelay} from "../../src/crosschain/RegistryRelay.sol"; +import {SatelliteOnboardingHelper} from "../../src/crosschain/SatelliteOnboardingHelper.sol"; +import {PoaManager} from "../../src/PoaManager.sol"; +import {IHybridVotingInit} from "../../src/libs/ModuleDeploymentLib.sol"; +import {RoleConfigStructs} from "../../src/libs/RoleConfigStructs.sol"; +import {ModulesFactory} from "../../src/factories/ModulesFactory.sol"; + +/** + * @title DeploySatelliteOrg + * @notice Deploys an org on the satellite chain using OrgDeployer.deployFullOrg, + * then deploys a SatelliteOnboardingHelper and authorizes it on the relay. + * + * Org name claim is dispatched optimistically to the hub during deployFullOrg + * (no separate pre-claim step needed). + * + * Required env vars: + * PRIVATE_KEY + * + * Reads: script/e2e/e2e-state.json + * Writes: script/e2e/e2e-state.json (adds org section) + */ +contract DeploySatelliteOrg is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + string memory state = vm.readFile("script/e2e/e2e-state.json"); + address orgDeployerAddr = vm.parseJsonAddress(state, ".satellite.orgDeployer"); + address relayAddr = vm.parseJsonAddress(state, ".satellite.registryRelay"); + address pmAddr = vm.parseJsonAddress(state, ".satellite.poaManager"); + + console.log("\n=== Deploy Satellite Org ==="); + console.log("Deployer:", deployer); + console.log("OrgDeployer:", orgDeployerAddr); + console.log("RegistryRelay:", relayAddr); + + vm.startBroadcast(deployerKey); + + // Build deployment params for a simple test org + OrgDeployer.DeploymentParams memory params = _buildOrgParams(deployer, relayAddr); + + // Deploy the org + OrgDeployer.DeploymentResult memory result = OrgDeployer(orgDeployerAddr).deployFullOrg(params); + + console.log("Org deployed!"); + console.log("Executor:", result.executor); + console.log("QuickJoin:", result.quickJoin); + console.log("HybridVoting:", result.hybridVoting); + + // Deploy SatelliteOnboardingHelper for this org + PoaManager pm = PoaManager(pmAddr); + address helperBeacon = pm.getBeaconById(keccak256("SatelliteOnboardingHelper")); + bytes memory helperInit = abi.encodeCall( + SatelliteOnboardingHelper.initialize, + (deployer, relayAddr, result.quickJoin, address(0)) // no passkey factory for e2e + ); + SatelliteOnboardingHelper helper = SatelliteOnboardingHelper(address(new BeaconProxy(helperBeacon, helperInit))); + console.log("SatelliteOnboardingHelper:", address(helper)); + + // Authorize the helper on the relay so it can call registerAccountForUser + RegistryRelay(payable(relayAddr)).setAuthorizedCaller(address(helper), true); + console.log("Helper authorized on relay"); + + vm.stopBroadcast(); + + // Write org addresses to state (append to satellite section) + _appendOrgState(state, result, address(helper)); + + console.log("\n=== Satellite Org Deployment Complete ==="); + console.log("NOTE: SatelliteOnboardingHelper is authorized on relay but NOT"); + console.log(" wired as QuickJoin's masterDeployAddress (requires governance)."); + console.log(" The helper can still dispatch username claims via the relay."); + } + + function _buildOrgParams(address deployer, address registryAddr) + internal + pure + returns (OrgDeployer.DeploymentParams memory params) + { + params.orgId = keccak256("e2e-sat-org"); + params.orgName = "E2ETestOrg"; + params.metadataHash = bytes32(0); + params.registryAddr = registryAddr; + params.deployerAddress = deployer; + params.deployerUsername = ""; + params.autoUpgrade = true; + params.hybridQuorumPct = 50; + params.ddQuorumPct = 50; + + // Simple 2-role config: MEMBER + CONTRIBUTOR + params.roles = new RoleConfigStructs.RoleConfig[](2); + address[] memory emptyAddrs = new address[](0); + + params.roles[0] = RoleConfigStructs.RoleConfig({ + name: "MEMBER", + image: "", + metadataCID: bytes32(0), + canVote: true, + vouching: RoleConfigStructs.RoleVouchingConfig({ + enabled: false, quorum: 0, voucherRoleIndex: 0, combineWithHierarchy: false + }), + defaults: RoleConfigStructs.RoleEligibilityDefaults({eligible: true, standing: true}), + hierarchy: RoleConfigStructs.RoleHierarchyConfig({adminRoleIndex: type(uint256).max}), + distribution: RoleConfigStructs.RoleDistributionConfig({ + mintToDeployer: false, additionalWearers: emptyAddrs + }), + hatConfig: RoleConfigStructs.HatConfig({maxSupply: type(uint32).max, mutableHat: true}) + }); + + params.roles[1] = RoleConfigStructs.RoleConfig({ + name: "CONTRIBUTOR", + image: "", + metadataCID: bytes32(0), + canVote: true, + vouching: RoleConfigStructs.RoleVouchingConfig({ + enabled: false, quorum: 0, voucherRoleIndex: 0, combineWithHierarchy: false + }), + defaults: RoleConfigStructs.RoleEligibilityDefaults({eligible: true, standing: true}), + hierarchy: RoleConfigStructs.RoleHierarchyConfig({adminRoleIndex: type(uint256).max}), + distribution: RoleConfigStructs.RoleDistributionConfig({ + mintToDeployer: true, additionalWearers: emptyAddrs + }), + hatConfig: RoleConfigStructs.HatConfig({maxSupply: type(uint32).max, mutableHat: true}) + }); + + // Voting classes + params.hybridClasses = new IHybridVotingInit.ClassConfig[](1); + uint256[] memory emptyHatIds = new uint256[](0); + params.hybridClasses[0] = IHybridVotingInit.ClassConfig({ + strategy: IHybridVotingInit.ClassStrategy.DIRECT, + slicePct: 100, + quadratic: false, + minBalance: 0, + asset: address(0), + hatIds: emptyHatIds + }); + + // Role assignments: MEMBER (index 0) gets quickJoin + params.roleAssignments = OrgDeployer.RoleAssignments({ + quickJoinRolesBitmap: 1, // bit 0 = MEMBER role + tokenMemberRolesBitmap: 3, + tokenApproverRolesBitmap: 2, + taskCreatorRolesBitmap: 2, + educationCreatorRolesBitmap: 2, + educationMemberRolesBitmap: 3, + hybridProposalCreatorRolesBitmap: 2, + ddVotingRolesBitmap: 3, + ddCreatorRolesBitmap: 2 + }); + + params.ddInitialTargets = new address[](0); + params.metadataAdminRoleIndex = type(uint256).max; + params.educationHubConfig = ModulesFactory.EducationHubConfig({enabled: false}); + params.passkeyEnabled = false; + params.paymasterConfig.operatorRoleIndex = type(uint256).max; + } + + function _appendOrgState(string memory existingState, OrgDeployer.DeploymentResult memory result, address helper) + internal + { + // Re-read all fields and rewrite with org section added + address ddAddr = vm.parseJsonAddress(existingState, ".deterministicDeployer"); + address homePm = vm.parseJsonAddress(existingState, ".homeChain.poaManager"); + address homeReg = vm.parseJsonAddress(existingState, ".homeChain.implRegistry"); + address homeHub = vm.parseJsonAddress(existingState, ".homeChain.hub"); + address homeHv = vm.parseJsonAddress(existingState, ".homeChain.hybridVotingV1"); + address homeUar = vm.parseJsonAddress(existingState, ".homeChain.uar"); + address nameHub = vm.parseJsonAddress(existingState, ".homeChain.nameRegistryHub"); + + address satPm = vm.parseJsonAddress(existingState, ".satellite.poaManager"); + address satReg = vm.parseJsonAddress(existingState, ".satellite.implRegistry"); + address satSat = vm.parseJsonAddress(existingState, ".satellite.satellite"); + address satRelay = vm.parseJsonAddress(existingState, ".satellite.registryRelay"); + address satAdapter = vm.parseJsonAddress(existingState, ".satellite.nameClaimAdapter"); + address satOrgReg = vm.parseJsonAddress(existingState, ".satellite.orgRegistry"); + address satOrgDep = vm.parseJsonAddress(existingState, ".satellite.orgDeployer"); + address satHv = vm.parseJsonAddress(existingState, ".satellite.hybridVotingV1"); + + string memory json1 = string.concat( + "{\n", + ' "deterministicDeployer": "', + vm.toString(ddAddr), + '",\n', + ' "homeChain": {\n', + ' "poaManager": "', + vm.toString(homePm), + '",\n', + ' "implRegistry": "', + vm.toString(homeReg), + '",\n', + ' "hub": "', + vm.toString(homeHub), + '",\n' + ); + + string memory json2 = string.concat( + ' "hybridVotingV1": "', + vm.toString(homeHv), + '",\n', + ' "uar": "', + vm.toString(homeUar), + '",\n', + ' "nameRegistryHub": "', + vm.toString(nameHub), + '"\n', + " },\n" + ); + + string memory json3 = string.concat( + ' "satellite": {\n', + ' "poaManager": "', + vm.toString(satPm), + '",\n', + ' "implRegistry": "', + vm.toString(satReg), + '",\n', + ' "satellite": "', + vm.toString(satSat), + '",\n', + ' "registryRelay": "', + vm.toString(satRelay), + '",\n' + ); + + string memory json4 = string.concat( + ' "nameClaimAdapter": "', + vm.toString(satAdapter), + '",\n', + ' "orgRegistry": "', + vm.toString(satOrgReg), + '",\n', + ' "orgDeployer": "', + vm.toString(satOrgDep), + '",\n', + ' "hybridVotingV1": "', + vm.toString(satHv), + '"\n', + " },\n" + ); + + string memory json5 = string.concat( + ' "satelliteOrg": {\n', + ' "executor": "', + vm.toString(result.executor), + '",\n', + ' "quickJoin": "', + vm.toString(result.quickJoin), + '",\n', + ' "hybridVoting": "', + vm.toString(result.hybridVoting), + '",\n', + ' "onboardingHelper": "', + vm.toString(helper), + '"\n', + " }\n", + "}\n" + ); + + vm.writeFile("script/e2e/e2e-state.json", string.concat(json1, json2, json3, json4, json5)); + } +} diff --git a/script/e2e/DispatchNameRegistryTest.s.sol b/script/e2e/DispatchNameRegistryTest.s.sol new file mode 100644 index 0000000..c71aae0 --- /dev/null +++ b/script/e2e/DispatchNameRegistryTest.s.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {RegistryRelay} from "../../src/crosschain/RegistryRelay.sol"; + +/** + * @title DispatchNameRegistryTest + * @notice Dispatches test username + org name claims from the satellite relay. + * After Hyperlane relays these messages, VerifyNameRegistry checks results + * on the home chain. + * + * Required env vars: + * PRIVATE_KEY, RELAY + */ +contract DispatchNameRegistryTest is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address relayAddr = vm.envAddress("RELAY"); + + RegistryRelay relay = RegistryRelay(payable(relayAddr)); + + console.log("\n=== Dispatching Name Registry Tests ==="); + console.log("Relay:", relayAddr); + console.log("User:", vm.addr(deployerKey)); + + vm.startBroadcast(deployerKey); + + // 1. Register a username via direct registration + relay.registerAccountDirect{value: 0.01 ether}("e2etestuser"); + console.log("Username claim dispatched: e2etestuser"); + + // 2. Claim an org name via owner-only path (separate from the optimistic + // claim that happens during deployFullOrg — uses a different name to avoid conflict) + relay.claimOrgName{value: 0.01 ether}("E2EManualClaim"); + console.log("Org name claim dispatched: E2EManualClaim"); + + vm.stopBroadcast(); + + console.log("\n=== Name Registry Tests Dispatched ==="); + } +} diff --git a/script/e2e/RegisterSatellite.s.sol b/script/e2e/RegisterSatellite.s.sol index 7bdc4f0..7750339 100644 --- a/script/e2e/RegisterSatellite.s.sol +++ b/script/e2e/RegisterSatellite.s.sol @@ -4,30 +4,42 @@ pragma solidity ^0.8.20; import "forge-std/Script.sol"; import "forge-std/console.sol"; import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {NameRegistryHub} from "../../src/crosschain/NameRegistryHub.sol"; /** * @title RegisterSatellite - * @notice Registers a satellite on the Hub. Run on the home chain. + * @notice Registers a satellite on the PoaManagerHub and a RegistryRelay on the + * NameRegistryHub. Run on the home chain. * * Required env vars: - * PRIVATE_KEY, HUB, SATELLITE_DOMAIN, SATELLITE_ADDRESS + * PRIVATE_KEY, HUB, NAME_HUB, SATELLITE_DOMAIN, SATELLITE_ADDRESS, RELAY_ADDRESS */ contract RegisterSatellite is Script { function run() public { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); address hubAddr = vm.envAddress("HUB"); + address nameHubAddr = vm.envAddress("NAME_HUB"); uint32 satDomain = uint32(vm.envUint("SATELLITE_DOMAIN")); address satAddr = vm.envAddress("SATELLITE_ADDRESS"); + address relayAddr = vm.envAddress("RELAY_ADDRESS"); console.log("\n=== Registering Satellite ==="); - console.log("Hub:", hubAddr); + console.log("PoaManagerHub:", hubAddr); + console.log("NameRegistryHub:", nameHubAddr); console.log("Satellite domain:", satDomain); console.log("Satellite address:", satAddr); + console.log("Relay address:", relayAddr); vm.startBroadcast(deployerKey); + PoaManagerHub(payable(hubAddr)).registerSatellite(satDomain, satAddr); + console.log("PoaManagerSatellite registered"); + + NameRegistryHub(payable(nameHubAddr)).registerSatellite(satDomain, relayAddr); + console.log("RegistryRelay registered"); + vm.stopBroadcast(); - console.log("Satellite registered successfully"); + console.log("Satellites registered successfully"); } } diff --git a/script/e2e/TestSatelliteOnboarding.s.sol b/script/e2e/TestSatelliteOnboarding.s.sol new file mode 100644 index 0000000..38ec711 --- /dev/null +++ b/script/e2e/TestSatelliteOnboarding.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {SatelliteOnboardingHelper} from "../../src/crosschain/SatelliteOnboardingHelper.sol"; +import {RegistryRelay} from "../../src/crosschain/RegistryRelay.sol"; +import {OrgRegistry} from "../../src/OrgRegistry.sol"; + +/** + * @title TestSatelliteOnboarding + * @notice Verifies the satellite org deployment and onboarding infrastructure is + * correctly wired, and dispatches a username claim via the direct relay path. + * + * Checks: + * 1. Org exists in OrgRegistry + * 2. SatelliteOnboardingHelper is deployed and authorized on relay + * 3. Dispatches a username claim from the satellite + * + * NOTE: Full optimistic onboarding (helper.registerAndJoin) requires the + * helper to be set as QuickJoin's masterDeployAddress via governance. + * This script tests the relay dispatch path which is the cross-chain part. + * + * Required env vars: + * PRIVATE_KEY + * + * Reads: script/e2e/e2e-state.json + */ +contract TestSatelliteOnboarding is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + + string memory state = vm.readFile("script/e2e/e2e-state.json"); + address helperAddr = vm.parseJsonAddress(state, ".satelliteOrg.onboardingHelper"); + address relayAddr = vm.parseJsonAddress(state, ".satellite.registryRelay"); + address orgRegistryAddr = vm.parseJsonAddress(state, ".satellite.orgRegistry"); + address executorAddr = vm.parseJsonAddress(state, ".satelliteOrg.executor"); + + console.log("\n=== Test Satellite Onboarding Infrastructure ==="); + + // 1. Verify org exists + OrgRegistry orgReg = OrgRegistry(orgRegistryAddr); + bytes32 orgId = keccak256("e2e-sat-org"); + (address orgExecutor,,, bool exists) = orgReg.orgOf(orgId); + console.log("Org executor from registry:", orgExecutor); + require(exists, "Org not found in registry"); + require(orgExecutor == executorAddr, "Executor mismatch"); + console.log("[PASS] Org deployed and registered"); + + // 2. Verify helper is authorized on relay + RegistryRelay relay = RegistryRelay(payable(relayAddr)); + bool authorized = relay.authorizedCallers(helperAddr); + require(authorized, "Helper not authorized on relay"); + console.log("[PASS] Helper authorized on relay"); + + // 3. Verify helper addresses + SatelliteOnboardingHelper helper = SatelliteOnboardingHelper(helperAddr); + require(address(helper.relay()) == relayAddr, "Helper relay mismatch"); + console.log("[PASS] Helper wired to relay"); + + // 4. Dispatch a username claim via direct relay path + vm.startBroadcast(deployerKey); + relay.registerAccountDirect{value: 0.01 ether}("e2esatorguser"); + vm.stopBroadcast(); + console.log("[PASS] Username claim dispatched from satellite: e2esatorguser"); + + console.log("\nPASS: Satellite onboarding infrastructure verified"); + } +} diff --git a/script/e2e/TestnetE2EHomeChain.s.sol b/script/e2e/TestnetE2EHomeChain.s.sol index e39d7c9..9797521 100644 --- a/script/e2e/TestnetE2EHomeChain.s.sol +++ b/script/e2e/TestnetE2EHomeChain.s.sol @@ -7,7 +7,9 @@ import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import {PoaManager} from "../../src/PoaManager.sol"; import {ImplementationRegistry} from "../../src/ImplementationRegistry.sol"; +import {UniversalAccountRegistry} from "../../src/UniversalAccountRegistry.sol"; import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {NameRegistryHub} from "../../src/crosschain/NameRegistryHub.sol"; import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; import {HybridVoting} from "../../src/HybridVoting.sol"; @@ -15,7 +17,8 @@ import {HybridVoting} from "../../src/HybridVoting.sol"; * @title TestnetE2EHomeChain * @notice Deploys minimal home-chain infrastructure for E2E cross-chain testing. * Deploys: PoaManager, ImplementationRegistry, HybridVoting v1 (via DeterministicDeployer), - * PoaManagerHub, and transfers PoaManager ownership to Hub. + * PoaManagerHub, UniversalAccountRegistry, NameRegistryHub, and transfers PoaManager + * ownership to Hub. * * Required env vars: * PRIVATE_KEY, DETERMINISTIC_DEPLOYER, MAILBOX @@ -64,18 +67,43 @@ contract TestnetE2EHomeChain is Script { } pm.addContractType("HybridVoting", hvImpl); - // 4. Deploy PoaManagerHub - PoaManagerHub hub = new PoaManagerHub(address(pm), mailboxAddr); + // 4. Deploy UniversalAccountRegistry behind beacon + // (must happen before ownership transfer since addContractType is onlyOwner) + address uarImpl = address(new UniversalAccountRegistry()); + pm.addContractType("UniversalAccountRegistry", uarImpl); + address uarBeacon = pm.getBeaconById(keccak256("UniversalAccountRegistry")); + bytes memory uarInit = abi.encodeWithSignature("initialize(address)", vm.addr(deployerKey)); + UniversalAccountRegistry uar = UniversalAccountRegistry(address(new BeaconProxy(uarBeacon, uarInit))); + console.log("UniversalAccountRegistry:", address(uar)); + + // 5. Deploy PoaManagerHub via BeaconProxy + address poaHubImpl = address(new PoaManagerHub()); + pm.addContractType("PoaManagerHub", poaHubImpl); + address poaHubBeacon = pm.getBeaconById(keccak256("PoaManagerHub")); + bytes memory poaHubInit = + abi.encodeCall(PoaManagerHub.initialize, (vm.addr(deployerKey), address(pm), mailboxAddr)); + PoaManagerHub hub = PoaManagerHub(payable(address(new BeaconProxy(poaHubBeacon, poaHubInit)))); console.log("PoaManagerHub:", address(hub)); - // 5. Transfer PoaManager ownership to Hub + // 6. Deploy NameRegistryHub via BeaconProxy and wire to UAR + address nameHubImpl = address(new NameRegistryHub()); + pm.addContractType("NameRegistryHub", nameHubImpl); + address nameHubBeacon = pm.getBeaconById(keccak256("NameRegistryHub")); + bytes memory nameHubInit = + abi.encodeCall(NameRegistryHub.initialize, (vm.addr(deployerKey), address(uar), mailboxAddr)); + NameRegistryHub nameHub = NameRegistryHub(payable(address(new BeaconProxy(nameHubBeacon, nameHubInit)))); + uar.setNameRegistryHub(address(nameHub)); + console.log("NameRegistryHub:", address(nameHub)); + console.log("UAR wired to NameRegistryHub"); + + // 7. Transfer PoaManager ownership to Hub (after all types registered) pm.transferOwnership(address(hub)); console.log("PoaManager ownership transferred to Hub"); vm.stopBroadcast(); - // 6. Write state JSON - string memory json = string.concat( + // 8. Write state JSON (step numbers above shifted: UAR=4, Hub=5, Transfer=6, NameHub=7) + string memory json1 = string.concat( "{\n", ' "deterministicDeployer": "', vm.toString(ddAddr), @@ -89,14 +117,24 @@ contract TestnetE2EHomeChain is Script { '",\n', ' "hub": "', vm.toString(address(hub)), - '",\n', + '",\n' + ); + + string memory json2 = string.concat( ' "hybridVotingV1": "', vm.toString(hvImpl), + '",\n', + ' "uar": "', + vm.toString(address(uar)), + '",\n', + ' "nameRegistryHub": "', + vm.toString(address(nameHub)), '"\n', " }\n", "}\n" ); - vm.writeFile("script/e2e/e2e-state.json", json); + + vm.writeFile("script/e2e/e2e-state.json", string.concat(json1, json2)); console.log("\n=== Home Chain E2E Setup Complete ==="); console.log("State written to script/e2e/e2e-state.json"); diff --git a/script/e2e/TestnetE2ESatellite.s.sol b/script/e2e/TestnetE2ESatellite.s.sol index 284ffcd..50a1e19 100644 --- a/script/e2e/TestnetE2ESatellite.s.sol +++ b/script/e2e/TestnetE2ESatellite.s.sol @@ -5,17 +5,28 @@ import "forge-std/Script.sol"; import "forge-std/console.sol"; import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {DeployHelper} from "../helpers/DeployHelper.s.sol"; import {PoaManager} from "../../src/PoaManager.sol"; import {ImplementationRegistry} from "../../src/ImplementationRegistry.sol"; +import {OrgRegistry} from "../../src/OrgRegistry.sol"; +import {OrgDeployer} from "../../src/OrgDeployer.sol"; +import {PaymasterHub} from "../../src/PaymasterHub.sol"; import {PoaManagerSatellite} from "../../src/crosschain/PoaManagerSatellite.sol"; +import {RegistryRelay} from "../../src/crosschain/RegistryRelay.sol"; +import {NameClaimAdapter} from "../../src/crosschain/NameClaimAdapter.sol"; import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; import {HybridVoting} from "../../src/HybridVoting.sol"; +import {GovernanceFactory} from "../../src/factories/GovernanceFactory.sol"; +import {AccessFactory} from "../../src/factories/AccessFactory.sol"; +import {ModulesFactory} from "../../src/factories/ModulesFactory.sol"; +import {HatsTreeSetup} from "../../src/HatsTreeSetup.sol"; /** * @title TestnetE2ESatellite - * @notice Deploys minimal satellite infrastructure for E2E cross-chain testing. - * Deploys: PoaManager, ImplementationRegistry, HybridVoting v1 (via DeterministicDeployer), - * PoaManagerSatellite, and transfers PoaManager ownership to Satellite. + * @notice Deploys full satellite infrastructure for E2E cross-chain testing. + * Deploys: PoaManager, ImplementationRegistry, all contract types (via DD), + * PoaManagerSatellite, RegistryRelay, NameClaimAdapter, OrgRegistry, OrgDeployer, + * factories, PaymasterHub, and transfers PoaManager ownership to Satellite. * * Required env vars: * PRIVATE_KEY, DETERMINISTIC_DEPLOYER, HUB_DOMAIN, HUB_ADDRESS, MAILBOX @@ -23,18 +34,28 @@ import {HybridVoting} from "../../src/HybridVoting.sol"; * Reads: script/e2e/e2e-state.json * Writes: script/e2e/e2e-state.json (full state with satellite section) */ -contract TestnetE2ESatellite is Script { +contract TestnetE2ESatellite is DeployHelper { + address public constant HATS_PROTOCOL = 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137; + address public constant ENTRY_POINT_V07 = 0x0000000071727De22E5E9d8BAf0edAc6f37da032; + uint256 public constant INITIAL_SOLIDARITY_FUND = 0.01 ether; + function run() public { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); uint32 hubDomain = uint32(vm.envUint("HUB_DOMAIN")); address hubAddress = vm.envAddress("HUB_ADDRESS"); address mailboxAddr = vm.envAddress("MAILBOX"); + // Read home chain state for NameRegistryHub address + string memory existing = vm.readFile("script/e2e/e2e-state.json"); + address nameHubAddress = vm.parseJsonAddress(existing, ".homeChain.nameRegistryHub"); + console.log("\n=== E2E Satellite Setup ==="); - console.log("Deployer:", vm.addr(deployerKey)); + console.log("Deployer:", deployer); console.log("Hub domain:", hubDomain); console.log("Hub address:", hubAddress); + console.log("NameRegistryHub:", nameHubAddress); console.log("Mailbox:", mailboxAddr); vm.startBroadcast(deployerKey); @@ -47,45 +68,160 @@ contract TestnetE2ESatellite is Script { ImplementationRegistry regImpl = new ImplementationRegistry(); pm.addContractType("ImplementationRegistry", address(regImpl)); address regBeacon = pm.getBeaconById(keccak256("ImplementationRegistry")); - bytes memory regInit = abi.encodeWithSignature("initialize(address)", vm.addr(deployerKey)); + bytes memory regInit = abi.encodeWithSignature("initialize(address)", deployer); ImplementationRegistry reg = ImplementationRegistry(address(new BeaconProxy(regBeacon, regInit))); pm.updateImplRegistry(address(reg)); reg.registerImplementation("ImplementationRegistry", "v1", address(regImpl), true); reg.transferOwnership(address(pm)); console.log("ImplementationRegistry:", address(reg)); - // 3. Deploy HybridVoting v1 via DeterministicDeployer (same address as home chain) + // 3. Deploy all application types via DeterministicDeployer DeterministicDeployer dd = DeterministicDeployer(ddAddr); - bytes32 salt = dd.computeSalt("HybridVoting", "v1"); - address predicted = dd.computeAddress(salt); + _deployAndRegisterTypesDD(pm, dd); + + // Also deploy HybridVoting v1 via DD for cross-chain upgrade testing + bytes32 hvSalt = dd.computeSalt("HybridVoting", "v1"); + address hvPredicted = dd.computeAddress(hvSalt); address hvImpl; - if (predicted.code.length > 0) { - hvImpl = predicted; + if (hvPredicted.code.length > 0) { + hvImpl = hvPredicted; console.log("HybridVoting v1 already deployed:", hvImpl); } else { - hvImpl = dd.deploy(salt, type(HybridVoting).creationCode); + hvImpl = dd.deploy(hvSalt, type(HybridVoting).creationCode); console.log("HybridVoting v1 deployed:", hvImpl); } - pm.addContractType("HybridVoting", hvImpl); - // 4. Deploy PoaManagerSatellite - PoaManagerSatellite satellite = new PoaManagerSatellite(address(pm), mailboxAddr, hubDomain, hubAddress); + // 4. Deploy PoaManagerSatellite via BeaconProxy + address satImpl = address(new PoaManagerSatellite()); + pm.addContractType("PoaManagerSatellite", satImpl); + address satBeacon = pm.getBeaconById(keccak256("PoaManagerSatellite")); + bytes memory satInit = + abi.encodeCall(PoaManagerSatellite.initialize, (deployer, address(pm), mailboxAddr, hubDomain, hubAddress)); + PoaManagerSatellite satellite = PoaManagerSatellite(payable(address(new BeaconProxy(satBeacon, satInit)))); console.log("PoaManagerSatellite:", address(satellite)); - // 5. Transfer PoaManager ownership to Satellite + // 5. Deploy RegistryRelay via BeaconProxy (points to NameRegistryHub on home chain) + address relayImpl = address(new RegistryRelay()); + pm.addContractType("RegistryRelay", relayImpl); + address relayBeacon = pm.getBeaconById(keccak256("RegistryRelay")); + bytes memory relayInit = + abi.encodeCall(RegistryRelay.initialize, (deployer, mailboxAddr, hubDomain, nameHubAddress)); + RegistryRelay relay = RegistryRelay(payable(address(new BeaconProxy(relayBeacon, relayInit)))); + console.log("RegistryRelay:", address(relay)); + + // 6. Deploy NameClaimAdapter (bridges OrgRegistry to RegistryRelay) + address adapterBeacon = pm.getBeaconById(keccak256("NameClaimAdapter")); + bytes memory adapterInit = abi.encodeCall(NameClaimAdapter.initialize, (deployer, address(relay))); + NameClaimAdapter nameClaimAdapter = NameClaimAdapter(address(new BeaconProxy(adapterBeacon, adapterInit))); + console.log("NameClaimAdapter:", address(nameClaimAdapter)); + + // 7. Deploy OrgRegistry + OrgDeployer for satellite org creation + address orgRegBeacon = pm.getBeaconById(keccak256("OrgRegistry")); + bytes memory orgRegInit = abi.encodeWithSignature("initialize(address,address)", deployer, HATS_PROTOCOL); + OrgRegistry satOrgRegistry = OrgRegistry(address(new BeaconProxy(orgRegBeacon, orgRegInit))); + satOrgRegistry.setNameRegistryHub(address(nameClaimAdapter)); + nameClaimAdapter.setAuthorizedCaller(address(satOrgRegistry), true); + relay.setAuthorizedCaller(address(nameClaimAdapter), true); + console.log("OrgRegistry:", address(satOrgRegistry)); + console.log("NameClaimAdapter authorized on relay for optimistic org name dispatch"); + + // 8. Deploy factories (stateless) + address govFactory = address(new GovernanceFactory()); + address accFactory = address(new AccessFactory()); + address modFactory = address(new ModulesFactory()); + address hatsSetup = address(new HatsTreeSetup()); + console.log("Factories deployed"); + + // 9. Deploy PaymasterHub + address paymasterHubImpl = address(new PaymasterHub()); + pm.addContractType("PaymasterHub", paymasterHubImpl); + address paymasterHubBeacon = pm.getBeaconById(keccak256("PaymasterHub")); + bytes memory paymasterHubInit = + abi.encodeWithSignature("initialize(address,address,address)", ENTRY_POINT_V07, HATS_PROTOCOL, address(pm)); + address satPaymasterHub = address(new BeaconProxy(paymasterHubBeacon, paymasterHubInit)); + PaymasterHub(payable(satPaymasterHub)).donateToSolidarity{value: INITIAL_SOLIDARITY_FUND}(); + console.log("PaymasterHub:", satPaymasterHub); + + // 10. Deploy OrgDeployer + address deployerBeacon = pm.getBeaconById(keccak256("OrgDeployer")); + address orgDeployerImpl = address(new OrgDeployer()); + pm.addContractType("OrgDeployer", orgDeployerImpl); + deployerBeacon = pm.getBeaconById(keccak256("OrgDeployer")); + bytes memory orgDeployerInit = abi.encodeWithSignature( + "initialize(address,address,address,address,address,address,address,address)", + govFactory, + accFactory, + modFactory, + address(pm), + address(satOrgRegistry), + HATS_PROTOCOL, + hatsSetup, + satPaymasterHub + ); + address satOrgDeployer = address(new BeaconProxy(deployerBeacon, orgDeployerInit)); + console.log("OrgDeployer:", satOrgDeployer); + + // Transfer OrgRegistry ownership to OrgDeployer + satOrgRegistry.transferOwnership(satOrgDeployer); + + // Authorize OrgDeployer on PaymasterHub + pm.adminCall(satPaymasterHub, abi.encodeWithSignature("setOrgRegistrar(address)", satOrgDeployer)); + + // Deploy PasskeyAccountFactory + address passkeyAccountBeacon = pm.getBeaconById(keccak256("PasskeyAccount")); + address passkeyFactoryBeaconAddr = pm.getBeaconById(keccak256("PasskeyAccountFactory")); + bytes memory passkeyFactoryInit = abi.encodeWithSignature( + "initialize(address,address,address,uint48)", + address(pm), + passkeyAccountBeacon, + address(0), // no guardian + uint48(7 days) + ); + address satPasskeyFactory = address(new BeaconProxy(passkeyFactoryBeaconAddr, passkeyFactoryInit)); + pm.adminCall(satOrgDeployer, abi.encodeWithSignature("setUniversalPasskeyFactory(address)", satPasskeyFactory)); + console.log("PasskeyAccountFactory:", satPasskeyFactory); + + // Register infrastructure for subgraph indexing + pm.registerInfrastructure( + satOrgDeployer, address(satOrgRegistry), address(reg), satPaymasterHub, address(relay), satPasskeyFactory + ); + console.log("Infrastructure registered for indexing"); + + // 11. Transfer PoaManager ownership to Satellite (after all types registered) pm.transferOwnership(address(satellite)); console.log("PoaManager ownership transferred to Satellite"); vm.stopBroadcast(); - // 6. Read existing home chain state and merge - string memory existing = vm.readFile("script/e2e/e2e-state.json"); + // 12. Write state JSON (merge with home chain) + _writeState( + existing, ddAddr, pm, reg, satellite, relay, nameClaimAdapter, satOrgRegistry, satOrgDeployer, hvImpl + ); + + console.log("\n=== Satellite E2E Setup Complete ==="); + console.log("State written to script/e2e/e2e-state.json"); + } + + function _writeState( + string memory existing, + address ddAddr, + PoaManager pm, + ImplementationRegistry reg, + PoaManagerSatellite satellite, + RegistryRelay relay, + NameClaimAdapter nameClaimAdapter, + OrgRegistry satOrgRegistry, + address satOrgDeployer, + address hvImpl + ) internal { address homePm = vm.parseJsonAddress(existing, ".homeChain.poaManager"); address homeReg = vm.parseJsonAddress(existing, ".homeChain.implRegistry"); address homeHub = vm.parseJsonAddress(existing, ".homeChain.hub"); address homeHv = vm.parseJsonAddress(existing, ".homeChain.hybridVotingV1"); + address homeUar = vm.parseJsonAddress(existing, ".homeChain.uar"); + address nameHubAddress = vm.parseJsonAddress(existing, ".homeChain.nameRegistryHub"); - string memory json = string.concat( + string memory json1 = string.concat( "{\n", ' "deterministicDeployer": "', vm.toString(ddAddr), @@ -99,14 +235,23 @@ contract TestnetE2ESatellite is Script { '",\n', ' "hub": "', vm.toString(homeHub), - '",\n', + '",\n' + ); + + string memory json2 = string.concat( ' "hybridVotingV1": "', vm.toString(homeHv), + '",\n', + ' "uar": "', + vm.toString(homeUar), + '",\n', + ' "nameRegistryHub": "', + vm.toString(nameHubAddress), '"\n', " },\n" ); - string memory json2 = string.concat( + string memory json3 = string.concat( ' "satellite": {\n', ' "poaManager": "', vm.toString(address(pm)), @@ -116,6 +261,24 @@ contract TestnetE2ESatellite is Script { '",\n', ' "satellite": "', vm.toString(address(satellite)), + '",\n' + ); + + string memory json4 = string.concat( + ' "registryRelay": "', + vm.toString(address(relay)), + '",\n', + ' "nameClaimAdapter": "', + vm.toString(address(nameClaimAdapter)), + '",\n', + ' "orgRegistry": "', + vm.toString(address(satOrgRegistry)), + '",\n' + ); + + string memory json5 = string.concat( + ' "orgDeployer": "', + vm.toString(satOrgDeployer), '",\n', ' "hybridVotingV1": "', vm.toString(hvImpl), @@ -124,9 +287,6 @@ contract TestnetE2ESatellite is Script { "}\n" ); - vm.writeFile("script/e2e/e2e-state.json", string.concat(json, json2)); - - console.log("\n=== Satellite E2E Setup Complete ==="); - console.log("State written to script/e2e/e2e-state.json"); + vm.writeFile("script/e2e/e2e-state.json", string.concat(json1, json2, json3, json4, json5)); } } diff --git a/script/e2e/VerifyNameRegistry.s.sol b/script/e2e/VerifyNameRegistry.s.sol new file mode 100644 index 0000000..b61ad8c --- /dev/null +++ b/script/e2e/VerifyNameRegistry.s.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {UniversalAccountRegistry} from "../../src/UniversalAccountRegistry.sol"; +import {NameRegistryHub} from "../../src/crosschain/NameRegistryHub.sol"; + +/** + * @title VerifyNameRegistry + * @notice Read-only verification that cross-chain username + org name claims + * were processed on the home chain. Reverts if not yet processed + * (shell script checks exit code for polling). + * + * Required env vars: + * UAR, NAME_HUB, USER + */ +contract VerifyNameRegistry is Script { + function run() public view { + address uarAddr = vm.envAddress("UAR"); + address nameHubAddr = vm.envAddress("NAME_HUB"); + address user = vm.envAddress("USER"); + + UniversalAccountRegistry uar = UniversalAccountRegistry(uarAddr); + NameRegistryHub nameHub = NameRegistryHub(payable(nameHubAddr)); + + // Check username + string memory username = uar.getUsername(user); + bool usernameOk = bytes(username).length > 0; + + // Check org name reservation (manual claim dispatched in Step 4b) + bytes32 orgNameHash = _hashName("E2EManualClaim"); + bool orgNameOk = nameHub.reservedOrgNames(orgNameHash); + + console.log("=== Verify Name Registry ==="); + console.log("UAR:", uarAddr); + console.log("NameRegistryHub:", nameHubAddr); + console.log("User:", user); + console.log("Username:", username); + console.log("Username registered:", usernameOk); + console.log("Org name reserved:", orgNameOk); + + if (usernameOk && orgNameOk) { + console.log("PASS: Name registry cross-chain verified"); + } else { + revert("PENDING: Name registry not yet synced"); + } + } + + function _hashName(string memory name) internal pure returns (bytes32) { + bytes memory b = bytes(name); + for (uint256 i; i < b.length; ++i) { + uint8 c = uint8(b[i]); + if (c >= 65 && c <= 90) b[i] = bytes1(c + 32); + } + return keccak256(b); + } +} diff --git a/script/e2e/VerifySatelliteOrgName.s.sol b/script/e2e/VerifySatelliteOrgName.s.sol new file mode 100644 index 0000000..3760f1a --- /dev/null +++ b/script/e2e/VerifySatelliteOrgName.s.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {RegistryRelay} from "../../src/crosschain/RegistryRelay.sol"; + +/** + * @title VerifySatelliteOrgName + * @notice Read-only verification that a cross-chain org name claim was confirmed + * back on the satellite relay via Hyperlane round-trip. The org deploys + * optimistically before this confirmation arrives. Reverts if not yet + * confirmed (shell script checks exit code for polling). + * + * Required env vars: + * RELAY, ORG_NAME + */ +contract VerifySatelliteOrgName is Script { + function run() public view { + address relayAddr = vm.envAddress("RELAY"); + string memory orgName = vm.envString("ORG_NAME"); + + RegistryRelay relay = RegistryRelay(payable(relayAddr)); + + bool confirmed = relay.isOrgNameConfirmed(orgName); + + console.log("=== Verify Satellite Org Name ==="); + console.log("Relay:", relayAddr); + console.log("Org name:", orgName); + console.log("Confirmed:", confirmed); + + if (confirmed) { + console.log("PASS: Org name confirmed on satellite"); + } else { + revert("PENDING: Org name not yet confirmed on satellite"); + } + } +} diff --git a/script/helpers/DeployHelper.s.sol b/script/helpers/DeployHelper.s.sol index 8f6b181..4d6a8b3 100644 --- a/script/helpers/DeployHelper.s.sol +++ b/script/helpers/DeployHelper.s.sol @@ -19,6 +19,10 @@ import {ToggleModule} from "../../src/ToggleModule.sol"; import {PasskeyAccount} from "../../src/PasskeyAccount.sol"; import {PasskeyAccountFactory} from "../../src/PasskeyAccountFactory.sol"; +// Cross-chain satellite contracts +import {NameClaimAdapter} from "../../src/crosschain/NameClaimAdapter.sol"; +import {SatelliteOnboardingHelper} from "../../src/crosschain/SatelliteOnboardingHelper.sol"; + import {PoaManager} from "../../src/PoaManager.sol"; import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; @@ -42,7 +46,7 @@ abstract contract DeployHelper is Script { /// OrgDeployer, PaymasterHub) are handled separately because they /// require special initialization (beacon proxies, ownership, etc.). function _contractTypes() internal pure returns (ContractType[] memory types) { - types = new ContractType[](13); + types = new ContractType[](15); types[0] = ContractType("HybridVoting", type(HybridVoting).creationCode); types[1] = ContractType("DirectDemocracyVoting", type(DirectDemocracyVoting).creationCode); types[2] = ContractType("Executor", type(Executor).creationCode); @@ -56,6 +60,8 @@ abstract contract DeployHelper is Script { types[10] = ContractType("ToggleModule", type(ToggleModule).creationCode); types[11] = ContractType("PasskeyAccount", type(PasskeyAccount).creationCode); types[12] = ContractType("PasskeyAccountFactory", type(PasskeyAccountFactory).creationCode); + types[13] = ContractType("NameClaimAdapter", type(NameClaimAdapter).creationCode); + types[14] = ContractType("SatelliteOnboardingHelper", type(SatelliteOnboardingHelper).creationCode); } /// @notice Deploy all application types directly and register on PoaManager (home chain). diff --git a/script/testnet-e2e.sh b/script/testnet-e2e.sh index 7729134..6acca65 100755 --- a/script/testnet-e2e.sh +++ b/script/testnet-e2e.sh @@ -2,18 +2,24 @@ set -euo pipefail ############################################################################# -# testnet-e2e.sh - End-to-end cross-chain beacon upgrade test +# testnet-e2e.sh - End-to-end cross-chain test suite # -# Tests the full cross-chain upgrade flow: +# Tests the full cross-chain flow: # Sepolia (home) <--Hyperlane--> Base Sepolia (satellite) # +# Test Coverage: +# 1. Cross-chain beacon upgrades (HybridVoting v1 -> v2) +# 2. Cross-chain name registry (username + org name claims) +# 3. Satellite org deployment (org name uniqueness via cross-chain) +# 4. Satellite onboarding infrastructure verification +# # Prerequisites: # - .env with PRIVATE_KEY (funded on both Sepolia and Base Sepolia) # - forge build must succeed # - jq (for JSON parsing: brew install jq) # # Usage: -# ./script/testnet-e2e.sh # Full deploy + upgrade test +# ./script/testnet-e2e.sh # Full deploy + all tests # ./script/testnet-e2e.sh --skip-deploy # Skip infrastructure, test upgrade only ############################################################################# @@ -115,7 +121,7 @@ echo "" ########################################################################### # STEP 2: Deploy Home Chain Infrastructure ########################################################################### -echo ">>> STEP 2: Deploy Home Chain (PoaManager + Hub + HybridVoting v1)..." +echo ">>> STEP 2: Deploy Home Chain (PoaManager + Hub + UAR + NameRegistryHub)..." DETERMINISTIC_DEPLOYER=$DD_ADDR \ MAILBOX=$HOME_MAILBOX \ forge script script/e2e/TestnetE2EHomeChain.s.sol:TestnetE2EHomeChain \ @@ -125,15 +131,20 @@ forge script script/e2e/TestnetE2EHomeChain.s.sol:TestnetE2EHomeChain \ echo "" HUB_ADDR=$(json_get "$STATE_FILE" "homeChain.hub") +NAME_HUB_ADDR=$(json_get "$STATE_FILE" "homeChain.nameRegistryHub") HOME_PM=$(json_get "$STATE_FILE" "homeChain.poaManager") -echo " Hub: $HUB_ADDR" +HOME_UAR=$(json_get "$STATE_FILE" "homeChain.uar") +echo " PoaManagerHub: $HUB_ADDR" +echo " NameRegistryHub: $NAME_HUB_ADDR" +echo " UAR: $HOME_UAR" echo " Home PoaManager: $HOME_PM" echo "" ########################################################################### -# STEP 3: Deploy Satellite Infrastructure +# STEP 3: Deploy Full Satellite Infrastructure ########################################################################### -echo ">>> STEP 3: Deploy Satellite (PoaManager + Satellite + HybridVoting v1)..." +echo ">>> STEP 3: Deploy Satellite (full infra: PoaManager, Satellite, RegistryRelay," +echo " NameClaimAdapter, OrgRegistry, OrgDeployer, factories, PaymasterHub)..." DETERMINISTIC_DEPLOYER=$DD_ADDR \ HUB_DOMAIN=$HOME_DOMAIN \ HUB_ADDRESS=$HUB_ADDR \ @@ -146,22 +157,38 @@ echo "" SAT_ADDR=$(json_get "$STATE_FILE" "satellite.satellite") SAT_PM=$(json_get "$STATE_FILE" "satellite.poaManager") +RELAY_ADDR=$(json_get "$STATE_FILE" "satellite.registryRelay") echo " Satellite: $SAT_ADDR" +echo " RegistryRelay: $RELAY_ADDR" echo " Satellite PoaManager: $SAT_PM" echo "" ########################################################################### -# STEP 4: Register Satellite on Hub +# STEP 4: Register Satellite on Hub + RegistryRelay on NameRegistryHub ########################################################################### -echo ">>> STEP 4: Register satellite on Hub..." +echo ">>> STEP 4: Register satellite + relay on hubs..." HUB=$HUB_ADDR \ +NAME_HUB=$NAME_HUB_ADDR \ SATELLITE_DOMAIN=$SAT_DOMAIN \ SATELLITE_ADDRESS=$SAT_ADDR \ +RELAY_ADDRESS=$RELAY_ADDR \ forge script script/e2e/RegisterSatellite.s.sol:RegisterSatellite \ --rpc-url $HOME_RPC \ --broadcast \ --slow -echo " Satellite registered." +echo " Satellite + relay registered." +echo "" + +########################################################################### +# STEP 4b: Dispatch Name Registry Tests on Satellite +########################################################################### +echo ">>> STEP 4b: Dispatch name registry test on satellite..." +RELAY=$RELAY_ADDR \ +forge script script/e2e/DispatchNameRegistryTest.s.sol:DispatchNameRegistryTest \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow +echo " Name registry test dispatched (username + org name)." echo "" else @@ -173,12 +200,17 @@ else fi DD_ADDR=$(json_get "$STATE_FILE" "deterministicDeployer") HUB_ADDR=$(json_get "$STATE_FILE" "homeChain.hub") + NAME_HUB_ADDR=$(json_get "$STATE_FILE" "homeChain.nameRegistryHub") HOME_PM=$(json_get "$STATE_FILE" "homeChain.poaManager") + HOME_UAR=$(json_get "$STATE_FILE" "homeChain.uar") SAT_ADDR=$(json_get "$STATE_FILE" "satellite.satellite") SAT_PM=$(json_get "$STATE_FILE" "satellite.poaManager") + RELAY_ADDR=$(json_get "$STATE_FILE" "satellite.registryRelay") echo " DeterministicDeployer: $DD_ADDR" echo " Hub: $HUB_ADDR" + echo " NameRegistryHub: $NAME_HUB_ADDR" echo " Satellite: $SAT_ADDR" + echo " RegistryRelay: $RELAY_ADDR" echo "" fi @@ -231,6 +263,7 @@ echo ">>> STEP 8: Waiting for Hyperlane relay to Base Sepolia..." echo " Polling every 30s, max 10 minutes." echo "" +UPGRADE_OK=false MAX_ATTEMPTS=20 ATTEMPT=0 while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do @@ -241,14 +274,9 @@ while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do DETERMINISTIC_DEPLOYER=$DD_ADDR \ forge script script/e2e/VerifyUpgrade.s.sol:VerifyUpgrade \ --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then - echo "" - echo "============================================================" - echo " SUCCESS: Cross-chain upgrade verified on both chains!" - echo "" - echo " Home (Sepolia): HybridVoting beacon -> V2" - echo " Satellite (Base Sepolia): HybridVoting beacon -> V2" - echo "============================================================" - exit 0 + UPGRADE_OK=true + echo " Upgrade verified on satellite." + break fi if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then @@ -256,18 +284,179 @@ while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do sleep 30 fi done +echo "" + +########################################################################### +# STEP 9: Verify Name Registry on Home Chain +########################################################################### +echo ">>> STEP 9: Verify name registry on home chain..." +echo " Checking username + org name were registered via cross-chain relay." +echo "" + +# Derive deployer address from private key +USER_ADDR=$(cast wallet address --private-key $PRIVATE_KEY) + +NAME_REG_OK=false +NR_MAX_ATTEMPTS=20 +NR_ATTEMPT=0 +while [ $NR_ATTEMPT -lt $NR_MAX_ATTEMPTS ]; do + NR_ATTEMPT=$((NR_ATTEMPT + 1)) + echo " Attempt $NR_ATTEMPT/$NR_MAX_ATTEMPTS..." + + if UAR=$HOME_UAR \ + NAME_HUB=$NAME_HUB_ADDR \ + USER=$USER_ADDR \ + forge script script/e2e/VerifyNameRegistry.s.sol:VerifyNameRegistry \ + --rpc-url $HOME_RPC 2>&1 | grep -q "PASS"; then + NAME_REG_OK=true + echo " Name registry verified on home chain." + break + fi + + if [ $NR_ATTEMPT -lt $NR_MAX_ATTEMPTS ]; then + echo " Not yet synced. Sleeping 30s..." + sleep 30 + fi +done +echo "" + +########################################################################### +# STEP 10: Deploy Org on Satellite Chain (optimistic — no pre-confirmation needed) +########################################################################### +echo ">>> STEP 10: Deploy org on satellite chain (optimistic org name claim)..." +SAT_ORG_OK=false +if forge script script/e2e/DeploySatelliteOrg.s.sol:DeploySatelliteOrg \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow 2>&1 | tee /tmp/e2e-sat-org.log; then + SAT_ORG_OK=true + echo " Satellite org deployed (org name claim dispatched optimistically)." +else + echo " Satellite org deployment failed." +fi +echo "" + +########################################################################### +# STEP 11: Verify Satellite Onboarding Infrastructure +########################################################################### +SAT_ONBOARD_OK=false +if $SAT_ORG_OK; then + echo ">>> STEP 11: Verify satellite onboarding infrastructure..." + if forge script script/e2e/TestSatelliteOnboarding.s.sol:TestSatelliteOnboarding \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow 2>&1 | grep -q "PASS"; then + SAT_ONBOARD_OK=true + echo " Satellite onboarding infrastructure verified." + else + echo " Satellite onboarding verification failed." + fi + echo "" +fi + +########################################################################### +# STEP 12: Verify Org Name Round-Trip on Satellite (Hyperlane confirmation) +########################################################################### +echo ">>> STEP 12: Verify org name confirmed back on satellite relay..." +echo " Waiting for Hyperlane to deliver MSG_CONFIRM_ORG_NAME to satellite." +echo " (Org already deployed optimistically — this just confirms the name.)" +echo "" + +ORG_NAME_SAT_OK=false +ON_MAX_ATTEMPTS=20 +ON_ATTEMPT=0 +while [ $ON_ATTEMPT -lt $ON_MAX_ATTEMPTS ]; do + ON_ATTEMPT=$((ON_ATTEMPT + 1)) + echo " Attempt $ON_ATTEMPT/$ON_MAX_ATTEMPTS..." + + if RELAY=$RELAY_ADDR \ + ORG_NAME="E2ETestOrg" \ + forge script script/e2e/VerifySatelliteOrgName.s.sol:VerifySatelliteOrgName \ + --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then + ORG_NAME_SAT_OK=true + echo " Org name confirmed on satellite relay." + break + fi + if [ $ON_ATTEMPT -lt $ON_MAX_ATTEMPTS ]; then + echo " Not yet confirmed. Sleeping 30s..." + sleep 30 + fi +done echo "" + +########################################################################### +# Final Results +########################################################################### +echo "============================================================" +echo " E2E Test Results" echo "============================================================" -echo " TIMEOUT: Satellite not upgraded after 10 minutes." echo "" -echo " This may be normal -- Hyperlane relay can take longer" -echo " on testnets. Check the Hyperlane Explorer:" -echo " https://explorer.hyperlane.xyz" + +if $UPGRADE_OK; then + echo " [PASS] Cross-chain beacon upgrade" + echo " Home (Sepolia): HybridVoting beacon -> V2" + echo " Satellite (Base Sepolia): HybridVoting beacon -> V2" +else + echo " [FAIL] Cross-chain beacon upgrade (timeout)" + echo " Satellite not upgraded after 10 minutes." + echo " Check: https://explorer.hyperlane.xyz" +fi +echo "" + +if $NAME_REG_OK; then + echo " [PASS] Cross-chain name registry" + echo " Username registered on home chain UAR" + echo " Org name reserved on NameRegistryHub" +else + echo " [FAIL] Cross-chain name registry (timeout)" + echo " Name registry not synced after 10 minutes." + echo " Check: https://explorer.hyperlane.xyz" +fi +echo "" + +if $SAT_ORG_OK; then + echo " [PASS] Satellite org deployment (optimistic)" + echo " Org deployed on satellite via OrgDeployer" + echo " Org name claim dispatched optimistically to hub" + echo " SatelliteOnboardingHelper deployed + authorized on relay" +else + echo " [FAIL] Satellite org deployment" + echo " Deployment failed — check logs" +fi +echo "" + +if $ORG_NAME_SAT_OK; then + echo " [PASS] Org name round-trip confirmation" + echo " Org name confirmed back on satellite relay" +else + echo " [FAIL] Org name round-trip confirmation (timeout)" + echo " Org name not confirmed on satellite after 10 minutes." +fi echo "" -echo " You can re-run verification manually:" -echo " POAMANAGER=$SAT_PM DETERMINISTIC_DEPLOYER=$DD_ADDR \\" -echo " forge script script/e2e/VerifyUpgrade.s.sol:VerifyUpgrade \\" -echo " --rpc-url base-sepolia" + +if ${SAT_ONBOARD_OK:-false}; then + echo " [PASS] Satellite onboarding infrastructure" + echo " Org registered, helper authorized, username dispatched" +else + echo " [FAIL] Satellite onboarding infrastructure" +fi +echo "" + echo "============================================================" -exit 1 + +ALL_PASS=true +$UPGRADE_OK || ALL_PASS=false +$NAME_REG_OK || ALL_PASS=false +$ORG_NAME_SAT_OK || ALL_PASS=false +$SAT_ORG_OK || ALL_PASS=false + +if $ALL_PASS; then + echo " ALL TESTS PASSED" + echo "============================================================" + exit 0 +else + echo " SOME TESTS FAILED — see above for details" + echo "============================================================" + exit 1 +fi diff --git a/src/OrgRegistry.sol b/src/OrgRegistry.sol index 8a5f349..3e7e88b 100644 --- a/src/OrgRegistry.sol +++ b/src/OrgRegistry.sol @@ -16,6 +16,12 @@ error NotOrgExecutor(); error NotOrgMetadataAdmin(); error OwnerOnlyDuringBootstrap(); // deployer tried after bootstrap error AutoUpgradeRequired(); // deployer must set autoUpgrade=true +error OrgNameTaken(); + +interface INameRegistryHubOrgNames { + function claimOrgNameLocal(bytes32 nameHash, string calldata orgName) external; + function changeOrgNameLocal(bytes32 oldHash, bytes32 newHash) external; +} /* ────────────────── Org Registry ────────────────── */ contract OrgRegistry is Initializable, OwnableUpgradeable { @@ -65,6 +71,11 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { mapping(bytes32 => uint256) metadataAdminHatOf; // Hats Protocol address for permission checks IHats hats; + // Cross-chain: org name uniqueness + mapping(bytes32 => bytes32) orgNameHashToOrgId; // keccak256(normalized_name) → orgId + mapping(bytes32 => bytes32) orgIdToNameHash; // orgId → name hash + // Cross-chain: NameRegistryHub address (0 = standalone mode, backward compatible) + address nameRegistryHub; } bytes32 private constant _STORAGE_SLOT = keccak256("poa.orgregistry.storage"); @@ -90,6 +101,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { ); event HatsTreeRegistered(bytes32 indexed orgId, uint256 topHatId, uint256[] roleHatIds); event OrgMetadataAdminHatSet(bytes32 indexed orgId, uint256 hatId); + event NameRegistryHubUpdated(address indexed hub); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -114,6 +126,17 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { return address(_layout().hats); } + /// @notice Set the NameRegistryHub for cross-chain org name uniqueness. + /// Set to address(0) to disable cross-chain checks (standalone mode). + function setNameRegistryHub(address hub) external onlyOwner { + _layout().nameRegistryHub = hub; + emit NameRegistryHubUpdated(hub); + } + + function nameRegistryHub() external view returns (address) { + return _layout().nameRegistryHub; + } + /* ═════════════════ ORG LOGIC ═════════════════ */ function registerOrg(bytes32 orgId, address executorAddr, bytes calldata name, bytes32 metadataHash) external @@ -125,6 +148,18 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { Layout storage l = _layout(); if (l.orgOf[orgId].exists) revert OrgExists(); + // Enforce org name uniqueness (local) + bytes32 nameHash = _normalizeAndHashOrgName(name); + if (l.orgNameHashToOrgId[nameHash] != bytes32(0)) revert OrgNameTaken(); + + // Cross-chain uniqueness check via hub (if configured) + if (l.nameRegistryHub != address(0)) { + INameRegistryHubOrgNames(l.nameRegistryHub).claimOrgNameLocal(nameHash, string(name)); + } + + l.orgNameHashToOrgId[nameHash] = orgId; + l.orgIdToNameHash[orgId] = nameHash; + l.orgOf[orgId] = OrgInfo({ executor: executorAddr, contractCount: 0, @@ -148,6 +183,18 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { Layout storage l = _layout(); if (l.orgOf[orgId].exists) revert OrgExists(); + // Enforce org name uniqueness (local) + bytes32 nameHash = _normalizeAndHashOrgName(name); + if (l.orgNameHashToOrgId[nameHash] != bytes32(0)) revert OrgNameTaken(); + + // Cross-chain uniqueness check via hub (if configured) + if (l.nameRegistryHub != address(0)) { + INameRegistryHubOrgNames(l.nameRegistryHub).claimOrgNameLocal(nameHash, string(name)); + } + + l.orgNameHashToOrgId[nameHash] = orgId; + l.orgIdToNameHash[orgId] = nameHash; + l.orgOf[orgId] = OrgInfo({ executor: address(0), // no executor yet contractCount: 0, @@ -181,12 +228,13 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { * @param newMetadataHash New IPFS metadata hash (bytes32) */ function updateOrgMeta(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { - ValidationLib.requireValidTitle(newName); + if (newName.length > 0) ValidationLib.requireValidTitle(newName); Layout storage l = _layout(); OrgInfo storage o = l.orgOf[orgId]; if (!o.exists) revert OrgUnknown(); if (msg.sender != o.executor) revert NotOrgExecutor(); + _updateOrgNameHash(l, orgId, newName); emit MetaUpdated(orgId, newName, newMetadataHash); } @@ -197,7 +245,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { * @param newMetadataHash New IPFS metadata hash (bytes32) */ function updateOrgMetaAsAdmin(bytes32 orgId, bytes calldata newName, bytes32 newMetadataHash) external { - ValidationLib.requireValidTitle(newName); + if (newName.length > 0) ValidationLib.requireValidTitle(newName); Layout storage l = _layout(); OrgInfo storage o = l.orgOf[orgId]; @@ -216,6 +264,7 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { if (!hats.isWearerOfHat(msg.sender, metadataAdminHat)) revert NotOrgMetadataAdmin(); + _updateOrgNameHash(l, orgId, newName); emit MetaUpdated(orgId, newName, newMetadataHash); } @@ -472,4 +521,53 @@ contract OrgRegistry is Initializable, OwnableUpgradeable { function getRoleHat(bytes32 orgId, uint256 roleIndex) external view returns (uint256) { return _layout().roleHatOf[orgId][roleIndex]; } + + /* ══════════ ORG NAME UNIQUENESS ══════════ */ + + /// @notice Check if an org name is already taken. + function isOrgNameTaken(bytes calldata name) external view returns (bool) { + bytes32 nameHash = _normalizeAndHashOrgName(name); + return _layout().orgNameHashToOrgId[nameHash] != bytes32(0); + } + + /// @notice Get the orgId that owns a given name. + function orgIdOfName(bytes calldata name) external view returns (bytes32) { + bytes32 nameHash = _normalizeAndHashOrgName(name); + return _layout().orgNameHashToOrgId[nameHash]; + } + + /* ══════════ INTERNAL ══════════ */ + + /// @dev Update org name hash mappings, enforcing uniqueness on name changes. + /// Skips if newName is empty (metadata-only update) or name hash unchanged. + function _updateOrgNameHash(Layout storage l, bytes32 orgId, bytes calldata newName) internal { + if (newName.length == 0) return; // metadata-only update, name not changing + + bytes32 newHash = _normalizeAndHashOrgName(newName); + bytes32 oldHash = l.orgIdToNameHash[orgId]; + + if (newHash == oldHash) return; // name unchanged + + // Check new name isn't taken by a different org (local) + if (l.orgNameHashToOrgId[newHash] != bytes32(0)) revert OrgNameTaken(); + + // Cross-chain uniqueness check via hub (if configured) + if (l.nameRegistryHub != address(0)) { + INameRegistryHubOrgNames(l.nameRegistryHub).changeOrgNameLocal(oldHash, newHash); + } + + // Release old name, claim new + delete l.orgNameHashToOrgId[oldHash]; + l.orgNameHashToOrgId[newHash] = orgId; + l.orgIdToNameHash[orgId] = newHash; + } + + /// @dev Normalize org name (lowercase) and hash it for uniqueness comparison. + function _normalizeAndHashOrgName(bytes memory name) internal pure returns (bytes32) { + for (uint256 i; i < name.length; ++i) { + uint8 c = uint8(name[i]); + if (c >= 65 && c <= 90) name[i] = bytes1(c + 32); // A-Z → a-z + } + return keccak256(name); + } } diff --git a/src/QuickJoin.sol b/src/QuickJoin.sol index bd2ab35..0019499 100644 --- a/src/QuickJoin.sol +++ b/src/QuickJoin.sol @@ -221,6 +221,22 @@ contract QuickJoin is Initializable, ContextUpgradeable, ReentrancyGuardUpgradea emit QuickJoined(_msgSender(), l.memberHatIds); } + /// @notice Onboard a user whose username is already confirmed. + /// @dev Callable by executor or masterDeployAddress (e.g. SatelliteOnboardingHelper). + function quickJoinForUser(address user) external onlyMasterDeploy nonReentrant { + if (user == address(0)) revert ZeroUser(); + + Layout storage l = _layout(); + string memory existing = l.accountRegistry.getUsername(user); + if (bytes(existing).length == 0) revert NoUsername(); + + if (l.memberHatIds.length > 0) { + IExecutorHatMinter(l.executor).mintHatsForUser(user, l.memberHatIds); + } + + emit QuickJoined(user, l.memberHatIds); + } + /* ───────── Passkey join paths ─────── */ /// @notice Master-deploy path for passkey onboarding diff --git a/src/UniversalAccountRegistry.sol b/src/UniversalAccountRegistry.sol index 06e94d4..5a0be96 100644 --- a/src/UniversalAccountRegistry.sol +++ b/src/UniversalAccountRegistry.sol @@ -15,6 +15,12 @@ interface IPasskeyFactory { returns (address); } +interface INameRegistryHub { + function claimUsernameLocal(bytes32 nameHash) external; + function changeUsernameLocal(bytes32 oldHash, bytes32 newHash) external; + function burnUsernameLocal(bytes32 nameHash) external; +} + contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { /*────────────────────────── Custom Errors ──────────────────────────*/ error UsernameEmpty(); @@ -28,6 +34,7 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { error InvalidNonce(); error InvalidSigner(); error PasskeyFactoryNotSet(); + error NotHub(); /*─────────────────────────── Constants ─────────────────────────────*/ uint256 private constant MAX_LEN = 64; @@ -51,6 +58,8 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { mapping(bytes32 => address) ownerOfUsernameHash; mapping(address => uint256) nonces; address passkeyFactory; + // Cross-chain: NameRegistryHub address (0 = standalone mode, backward compatible) + address nameRegistryHub; } bytes32 private constant _STORAGE_SLOT = keccak256("poa.universalaccountregistry.storage"); @@ -68,6 +77,7 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { event UserDeleted(address indexed user, string oldUsername); event BatchRegistered(uint256 count); event PasskeyFactoryUpdated(address indexed factory); + event NameRegistryHubUpdated(address indexed hub); /// @custom:oz-upgrades-unsafe-allow constructor constructor() { @@ -86,6 +96,13 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { emit PasskeyFactoryUpdated(factory); } + /// @notice Set the NameRegistryHub for cross-chain uniqueness. + /// Set to address(0) to disable cross-chain checks (standalone mode). + function setNameRegistryHub(address hub) external onlyOwner { + _layout().nameRegistryHub = hub; + emit NameRegistryHubUpdated(hub); + } + /*──────────────────── Public Registration API ─────────────────────*/ function registerAccount(string calldata username) external { _register(msg.sender, username); @@ -206,11 +223,17 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { (bytes32 newHash, string memory norm) = _validate(newUsername); if (l.ownerOfUsernameHash[newHash] != address(0)) revert UsernameTaken(); + bytes32 oldHash = keccak256(bytes(_toLower(oldName))); + + // Global uniqueness check via hub (if configured) + if (l.nameRegistryHub != address(0)) { + INameRegistryHub(l.nameRegistryHub).changeUsernameLocal(oldHash, newHash); + } + // reserve new l.ownerOfUsernameHash[newHash] = msg.sender; // keep old reserved forever by burning ownership - bytes32 oldHash = keccak256(bytes(_toLower(oldName))); l.ownerOfUsernameHash[oldHash] = BURN_ADDRESS; l.addressToUsername[msg.sender] = norm; @@ -227,6 +250,12 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { if (bytes(oldName).length == 0) revert AccountUnknown(); bytes32 oldHash = keccak256(bytes(_toLower(oldName))); + + // Notify hub (if configured) — name stays reserved (burned) + if (l.nameRegistryHub != address(0)) { + INameRegistryHub(l.nameRegistryHub).burnUsernameLocal(oldHash); + } + l.ownerOfUsernameHash[oldHash] = BURN_ADDRESS; delete l.addressToUsername[msg.sender]; @@ -259,6 +288,10 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { return _layout().passkeyFactory; } + function nameRegistryHub() external view returns (address) { + return _layout().nameRegistryHub; + } + /// @notice Returns the EIP-712 domain separator. // solhint-disable-next-line func-name-mixedcase function DOMAIN_SEPARATOR() external view returns (bytes32) { @@ -270,6 +303,64 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { return keccak256(abi.encode(_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this))); } + /*──────────── Cross-chain entry points ────────────────────────────*/ + + /// @notice Called by NameRegistryHub for registrations originating from satellite chains. + /// @dev Does NOT call back to hub.claimUsernameLocal() — the hub already manages + /// its own reserved[] mapping directly in _handleClaimUsername. Using _register() + /// here would re-enter the hub via claimUsernameLocal, causing a redundant write. + function registerAccountCrossChain(address user, string calldata username) external { + if (msg.sender != _layout().nameRegistryHub) revert NotHub(); + + Layout storage l = _layout(); + if (bytes(l.addressToUsername[user]).length != 0) revert AccountExists(); + + (bytes32 hash, string memory norm) = _validate(username); + if (l.ownerOfUsernameHash[hash] != address(0)) revert UsernameTaken(); + + l.ownerOfUsernameHash[hash] = user; + l.addressToUsername[user] = norm; + + emit UserRegistered(user, norm); + } + + /// @notice Called by NameRegistryHub for username changes originating from satellite chains. + /// @dev Does NOT call hub.changeUsernameLocal() — the hub already manages reserved[] + /// directly in _handleChangeUsername. The local changeUsername() calls the hub + /// because it's the only code path for home-chain changes; here the hub is the caller. + function changeUsernameCrossChain(address user, string calldata newUsername) external { + if (msg.sender != _layout().nameRegistryHub) revert NotHub(); + + Layout storage l = _layout(); + string storage oldName = l.addressToUsername[user]; + if (bytes(oldName).length == 0) revert AccountUnknown(); + + (bytes32 newHash, string memory norm) = _validate(newUsername); + if (l.ownerOfUsernameHash[newHash] != address(0)) revert UsernameTaken(); + + l.ownerOfUsernameHash[newHash] = user; + bytes32 oldHash = keccak256(bytes(_toLower(oldName))); + l.ownerOfUsernameHash[oldHash] = BURN_ADDRESS; + l.addressToUsername[user] = norm; + + emit UsernameChanged(user, norm); + } + + /// @notice Called by NameRegistryHub for account deletions originating from satellite chains. + function deleteAccountCrossChain(address user) external { + if (msg.sender != _layout().nameRegistryHub) revert NotHub(); + + Layout storage l = _layout(); + string storage oldName = l.addressToUsername[user]; + if (bytes(oldName).length == 0) revert AccountUnknown(); + + bytes32 oldHash = keccak256(bytes(_toLower(oldName))); + l.ownerOfUsernameHash[oldHash] = BURN_ADDRESS; + delete l.addressToUsername[user]; + + emit UserDeleted(user, oldName); + } + /*──────────────────── Internal Registration ───────────────────────*/ function _register(address user, string calldata username) internal { Layout storage l = _layout(); @@ -278,6 +369,11 @@ contract UniversalAccountRegistry is Initializable, OwnableUpgradeable { (bytes32 hash, string memory norm) = _validate(username); if (l.ownerOfUsernameHash[hash] != address(0)) revert UsernameTaken(); + // Global uniqueness check via NameRegistryHub (if configured) + if (l.nameRegistryHub != address(0)) { + INameRegistryHub(l.nameRegistryHub).claimUsernameLocal(hash); + } + l.ownerOfUsernameHash[hash] = user; l.addressToUsername[user] = norm; diff --git a/src/crosschain/NameClaimAdapter.sol b/src/crosschain/NameClaimAdapter.sol new file mode 100644 index 0000000..5f5033e --- /dev/null +++ b/src/crosschain/NameClaimAdapter.sol @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; + +/// @notice Interface for RegistryRelay — read confirmed names + dispatch claims/releases. +interface IRegistryRelay { + function confirmedOrgNames(bytes32 nameHash) external view returns (bool); + function dispatchOrgNameClaim(string calldata orgName) external; + function dispatchOrgNameRelease(bytes32 nameHash) external; +} + +/// @title NameClaimAdapter +/// @notice Satellite-chain adapter that bridges OrgRegistry's synchronous +/// `INameRegistryHubOrgNames` interface to RegistryRelay's async +/// Hyperlane dispatch. +/// @dev On the home chain, OrgRegistry calls NameRegistryHub.claimOrgNameLocal() +/// directly (synchronous). On satellites, OrgRegistry points its +/// `nameRegistryHub` at this adapter instead. +/// +/// On initial claim, the adapter dispatches the claim to the hub +/// optimistically via the relay (using pre-funded relay ETH). +/// On rename, the adapter verifies the new name is pre-confirmed +/// and dispatches a release of the old name to the hub. +/// +/// IMPORTANT — Rejection recovery: +/// Initial claims are optimistic: the org deploys before the hub confirms. +/// If the hub rejects the name (e.g. already taken globally), the org exists +/// on the satellite with a locally invalid name. There is no automatic retry. +/// The RegistryRelay emits `OrgNameRejected` — operators/frontend should +/// monitor this event. Recovery requires org governance to call +/// `OrgRegistry.updateOrgMeta()` with a new, pre-confirmed name. +/// +/// Deploy behind a BeaconProxy. +contract NameClaimAdapter is Initializable, OwnableUpgradeable { + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.nameclaimadapter.storage + struct Layout { + IRegistryRelay relay; + mapping(address => bool) authorizedCallers; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.nameclaimadapter.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /*──────────── Errors ──────────*/ + error ZeroAddress(); + error CannotRenounce(); + error NotAuthorized(); + error NameNotConfirmed(); + + /*──────────── Events ──────────*/ + event OrgNameConsumed(bytes32 indexed nameHash); + event OrgNameReleased(bytes32 indexed nameHash); + event AuthorizedCallerSet(address indexed caller, bool authorized); + + /*──────────── Constructor ─────────*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + function initialize(address owner, address _relay) external initializer { + if (owner == address(0) || _relay == address(0)) revert ZeroAddress(); + __Ownable_init(owner); + _layout().relay = IRegistryRelay(_relay); + } + + /*══════════════════ INameRegistryHubOrgNames ══════════════════*/ + + /// @notice Dispatch an optimistic org name claim to the hub via the relay. + /// @dev Called by OrgRegistry.registerOrg() / createOrgBootstrap(). + /// Dispatches the claim immediately — org deploys optimistically + /// while the hub confirms/rejects in the background. + function claimOrgNameLocal(bytes32 nameHash, string calldata orgName) external { + Layout storage s = _layout(); + if (!s.authorizedCallers[msg.sender]) revert NotAuthorized(); + + // Dispatch claim to hub optimistically via pre-funded relay + s.relay.dispatchOrgNameClaim(orgName); + + emit OrgNameConsumed(nameHash); + } + + /// @notice Handle org name change: verify new name is confirmed, release old name on hub. + /// @dev Called by OrgRegistry.updateOrgMeta(). The new name must have been + /// pre-claimed via RegistryRelay.claimOrgName() before this call. + /// Automatically dispatches a release of the old name to the hub + /// via the relay (using pre-funded relay ETH). + function changeOrgNameLocal(bytes32 oldHash, bytes32 newHash) external { + Layout storage s = _layout(); + if (!s.authorizedCallers[msg.sender]) revert NotAuthorized(); + if (!s.relay.confirmedOrgNames(newHash)) revert NameNotConfirmed(); + + // Release old name on hub (fire-and-forget via pre-funded relay) + s.relay.dispatchOrgNameRelease(oldHash); + + emit OrgNameReleased(oldHash); + emit OrgNameConsumed(newHash); + } + + /*══════════════════ Admin ══════════════════*/ + + function setAuthorizedCaller(address caller, bool authorized) external onlyOwner { + if (caller == address(0)) revert ZeroAddress(); + _layout().authorizedCallers[caller] = authorized; + emit AuthorizedCallerSet(caller, authorized); + } + + /// @dev Ownership cannot be renounced. + function renounceOwnership() public pure override { + revert CannotRenounce(); + } + + /*══════════════════ Public Getters ══════════════════*/ + + function relay() external view returns (IRegistryRelay) { + return _layout().relay; + } + + function authorizedCallers(address caller) external view returns (bool) { + return _layout().authorizedCallers[caller]; + } +} diff --git a/src/crosschain/NameRegistryHub.sol b/src/crosschain/NameRegistryHub.sol new file mode 100644 index 0000000..5b1b6d2 --- /dev/null +++ b/src/crosschain/NameRegistryHub.sol @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import {IMailbox, IMessageRecipient} from "./interfaces/IHyperlane.sol"; + +/// @notice Minimal interface for UAR cross-chain registration. +interface IAccountRegistryCrossChain { + function registerAccountCrossChain(address user, string calldata username) external; + function changeUsernameCrossChain(address user, string calldata newUsername) external; + function deleteAccountCrossChain(address user) external; +} + +/// @title NameRegistryHub +/// @notice Home-chain (Arbitrum) hub for globally unique username and org name registration. +/// Receives claims from satellite relays via Hyperlane, registers on the +/// canonical UniversalAccountRegistry (usernames) or reserves globally (org names), +/// and dispatches confirm/reject back. +/// @dev Deploy behind a BeaconProxy. Bidirectional: implements IMessageRecipient +/// (receives from satellites) AND dispatches responses back through the same Mailbox. +/// The confirm/reject dispatch happens in the same tx as handle(). +contract NameRegistryHub is Initializable, OwnableUpgradeable, IMessageRecipient { + /*──────────── Types ───────────*/ + struct SatelliteConfig { + uint32 domain; + bytes32 satellite; + bool active; + } + + /*──────────── Constants ───────────*/ + uint8 internal constant MSG_CLAIM_USERNAME = 0x01; + uint8 internal constant MSG_CONFIRM_USERNAME = 0x02; + uint8 internal constant MSG_REJECT_USERNAME = 0x03; + uint8 internal constant MSG_BURN_USERNAME = 0x04; + uint8 internal constant MSG_CHANGE_USERNAME = 0x05; + uint8 internal constant MSG_CLAIM_ORG_NAME = 0x06; + uint8 internal constant MSG_CONFIRM_ORG_NAME = 0x07; + uint8 internal constant MSG_REJECT_ORG_NAME = 0x08; + uint8 internal constant MSG_RELEASE_ORG_NAME = 0x09; + + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.nameregistryhub.storage + struct Layout { + IAccountRegistryCrossChain accountRegistry; + IMailbox mailbox; + SatelliteConfig[] satellites; + bool paused; + mapping(bytes32 => bool) reserved; + mapping(bytes32 => bool) reservedOrgNames; + mapping(address => bool) authorizedOrgRegistries; + uint256 returnFee; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.nameregistryhub.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /*──────────── Errors ──────────────*/ + error IsPaused(); + error ZeroAddress(); + error CannotRenounce(); + error TransferFailed(); + error DuplicateDomain(uint32 domain); + error UnauthorizedMailbox(); + error NotAccountRegistry(); + error UnauthorizedSatellite(); + error UnknownMessageType(); + error NameTaken(); + error NameNotReserved(); + error OrgNameTaken(); + error NotOrgRegistry(); + error InsufficientBalance(); + + /*──────────── Events ──────────────*/ + event UsernameReserved(bytes32 indexed nameHash, uint32 indexed originDomain, address user); + event UsernameRejected(bytes32 indexed nameHash, uint32 indexed originDomain, address user); + event UsernameBurned(bytes32 indexed nameHash, uint32 indexed originDomain, address user); + event UsernameChanged( + bytes32 indexed oldNameHash, bytes32 indexed newNameHash, uint32 indexed originDomain, address user + ); + event OrgNameReserved(bytes32 indexed nameHash, uint32 indexed originDomain); + event OrgNameRejected(bytes32 indexed nameHash, uint32 indexed originDomain); + + event OrgNameBurned(bytes32 indexed nameHash); + event OrgNameReleased(bytes32 indexed nameHash, uint32 indexed originDomain); + event OrgRegistryAuthorized(address indexed registry, bool authorized); + event SatelliteRegistered(uint32 indexed domain, address satellite); + event SatelliteRemoved(uint32 indexed domain); + event PauseSet(bool paused); + event ReturnFeeSet(uint256 fee); + + /*──────────── Constructor ─────────*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + function initialize(address owner, address _accountRegistry, address _mailbox) external initializer { + if (owner == address(0) || _accountRegistry == address(0) || _mailbox == address(0)) revert ZeroAddress(); + __Ownable_init(owner); + Layout storage s = _layout(); + s.accountRegistry = IAccountRegistryCrossChain(_accountRegistry); + s.mailbox = IMailbox(_mailbox); + } + + /*══════════════════ Hyperlane Receiver ══════════════════*/ + + /// @notice Called by the Hyperlane Mailbox when a message arrives from a satellite relay. + /// @dev Validates origin + sender against registered satellites, then processes + /// the claim and dispatches confirm/reject back in the same transaction. + function handle(uint32 _origin, bytes32 _sender, bytes calldata _body) external override { + Layout storage s = _layout(); + if (msg.sender != address(s.mailbox)) revert UnauthorizedMailbox(); + if (s.paused) revert IsPaused(); + if (!_isRegisteredSatellite(s, _origin, _sender)) revert UnauthorizedSatellite(); + + uint8 msgType = abi.decode(_body[:32], (uint8)); + + if (msgType == MSG_CLAIM_USERNAME) { + _handleClaimUsername(s, _origin, _sender, _body); + } else if (msgType == MSG_BURN_USERNAME) { + _handleBurnUsername(s, _origin, _body); + } else if (msgType == MSG_CHANGE_USERNAME) { + _handleChangeUsername(s, _origin, _sender, _body); + } else if (msgType == MSG_CLAIM_ORG_NAME) { + _handleClaimOrgName(s, _origin, _sender, _body); + } else if (msgType == MSG_RELEASE_ORG_NAME) { + _handleReleaseOrgName(s, _origin, _body); + } else { + revert UnknownMessageType(); + } + } + + /*══════════════════ Home-Chain Shortcut ══════════════════*/ + + /// @notice Called by the home-chain UniversalAccountRegistry during local registration. + /// @dev Synchronous — reverts if name is taken, no Hyperlane involved. + function claimUsernameLocal(bytes32 nameHash) external { + Layout storage s = _layout(); + if (msg.sender != address(s.accountRegistry)) revert NotAccountRegistry(); + if (s.reserved[nameHash]) revert NameTaken(); + s.reserved[nameHash] = true; + } + + /// @notice Called by the home-chain UAR during local username change. + function changeUsernameLocal(bytes32, bytes32 newHash) external { + Layout storage s = _layout(); + if (msg.sender != address(s.accountRegistry)) revert NotAccountRegistry(); + if (s.reserved[newHash]) revert NameTaken(); + s.reserved[newHash] = true; + // Old name stays reserved (burned) — cannot be reclaimed + } + + /// @notice Called by the home-chain UAR during local username delete. + function burnUsernameLocal(bytes32) external view { + Layout storage s = _layout(); + if (msg.sender != address(s.accountRegistry)) revert NotAccountRegistry(); + // Name stays reserved — burned names can never be reclaimed (POP invariant) + } + + /*══════════════════ Home-Chain Shortcut: Org Names ══════════════════*/ + + /// @notice Called by the home-chain OrgRegistry during local org creation. + /// @dev Synchronous — reverts if name is taken, no Hyperlane involved. + /// The orgName param is unused on home chain (hash is sufficient). + function claimOrgNameLocal(bytes32 nameHash, string calldata) external { + Layout storage s = _layout(); + if (!s.authorizedOrgRegistries[msg.sender]) revert NotOrgRegistry(); + if (s.reservedOrgNames[nameHash]) revert OrgNameTaken(); + s.reservedOrgNames[nameHash] = true; + } + + /// @notice Called by the home-chain OrgRegistry during org name change. + /// @dev Old name is released (unlike usernames, org names CAN be reclaimed after rename). + function changeOrgNameLocal(bytes32 oldHash, bytes32 newHash) external { + Layout storage s = _layout(); + if (!s.authorizedOrgRegistries[msg.sender]) revert NotOrgRegistry(); + if (s.reservedOrgNames[newHash]) revert OrgNameTaken(); + delete s.reservedOrgNames[oldHash]; + s.reservedOrgNames[newHash] = true; + } + + /*══════════════════ Satellite Management ══════════════════*/ + + function registerSatellite(uint32 domain, address satellite) external onlyOwner { + if (satellite == address(0)) revert ZeroAddress(); + Layout storage s = _layout(); + + uint256 len = s.satellites.length; + for (uint256 i; i < len;) { + if (s.satellites[i].domain == domain && s.satellites[i].active) { + revert DuplicateDomain(domain); + } + unchecked { + ++i; + } + } + + s.satellites + .push(SatelliteConfig({domain: domain, satellite: bytes32(uint256(uint160(satellite))), active: true})); + emit SatelliteRegistered(domain, satellite); + } + + function removeSatellite(uint256 index) external onlyOwner { + Layout storage s = _layout(); + uint32 domain = s.satellites[index].domain; + s.satellites[index].active = false; + emit SatelliteRemoved(domain); + } + + function satelliteCount() external view returns (uint256) { + return _layout().satellites.length; + } + + /*══════════════════ Public Getters ══════════════════*/ + + function accountRegistry() external view returns (IAccountRegistryCrossChain) { + return _layout().accountRegistry; + } + + function mailbox() external view returns (IMailbox) { + return _layout().mailbox; + } + + function satellites(uint256 index) external view returns (uint32 domain, bytes32 satellite, bool active) { + Layout storage s = _layout(); + SatelliteConfig storage sat = s.satellites[index]; + return (sat.domain, sat.satellite, sat.active); + } + + function paused() external view returns (bool) { + return _layout().paused; + } + + function reserved(bytes32 nameHash) external view returns (bool) { + return _layout().reserved[nameHash]; + } + + function reservedOrgNames(bytes32 nameHash) external view returns (bool) { + return _layout().reservedOrgNames[nameHash]; + } + + function authorizedOrgRegistries(address registry) external view returns (bool) { + return _layout().authorizedOrgRegistries[registry]; + } + + function returnFee() external view returns (uint256) { + return _layout().returnFee; + } + + /*══════════════════ Admin ══════════════════*/ + + function setPaused(bool _paused) external onlyOwner { + _layout().paused = _paused; + emit PauseSet(_paused); + } + + function setReturnFee(uint256 _fee) external onlyOwner { + _layout().returnFee = _fee; + emit ReturnFeeSet(_fee); + } + + /// @dev Ownership cannot be renounced — losing it bricks the Hub permanently. + function renounceOwnership() public pure override { + revert CannotRenounce(); + } + + /// @notice Rescue ETH stuck in this contract. + function withdrawETH(address payable to) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + uint256 balance = address(this).balance; + (bool ok,) = to.call{value: balance}(""); + if (!ok) revert TransferFailed(); + } + + /// @notice Admin burn: permanently reserve a username (e.g. to block offensive names). + function adminBurn(bytes32 nameHash) external onlyOwner { + _layout().reserved[nameHash] = true; + } + + /// @notice Admin burn: permanently reserve an org name. + function adminBurnOrgName(bytes32 nameHash) external onlyOwner { + _layout().reservedOrgNames[nameHash] = true; + emit OrgNameBurned(nameHash); + } + + /// @notice Authorize an OrgRegistry to call *OrgNameLocal functions. + function setAuthorizedOrgRegistry(address registry, bool authorized) external onlyOwner { + if (registry == address(0)) revert ZeroAddress(); + _layout().authorizedOrgRegistries[registry] = authorized; + emit OrgRegistryAuthorized(registry, authorized); + } + + /// @dev Accept ETH for return-dispatch fees. + receive() external payable {} + + /*══════════════════ Internal: Message Handlers ══════════════════*/ + + function _handleClaimUsername(Layout storage s, uint32 _origin, bytes32 _sender, bytes calldata _body) internal { + (, address user, string memory username) = abi.decode(_body, (uint8, address, string)); + bytes32 nameHash = _hashUsername(username); + + // Pre-check: reject if name is already reserved (e.g. admin burn, prior claim) + if (s.reserved[nameHash]) { + emit UsernameRejected(nameHash, _origin, user); + bytes memory reject = abi.encode(MSG_REJECT_USERNAME, user, username); + _dispatchToSatellite(s, _origin, _sender, reject); + return; + } + + // Try to register on canonical UAR + try s.accountRegistry.registerAccountCrossChain(user, username) { + s.reserved[nameHash] = true; + emit UsernameReserved(nameHash, _origin, user); + + bytes memory confirm = abi.encode(MSG_CONFIRM_USERNAME, user, username); + _dispatchToSatellite(s, _origin, _sender, confirm); + } catch { + emit UsernameRejected(nameHash, _origin, user); + + bytes memory reject = abi.encode(MSG_REJECT_USERNAME, user, username); + _dispatchToSatellite(s, _origin, _sender, reject); + } + } + + function _handleBurnUsername(Layout storage s, uint32, bytes calldata _body) internal { + (, address user) = abi.decode(_body, (uint8, address)); + + // Fire-and-forget: delete on canonical UAR. Name stays reserved. + try s.accountRegistry.deleteAccountCrossChain(user) {} catch {} + + // No response needed — burn is permanent regardless + } + + function _handleChangeUsername(Layout storage s, uint32 _origin, bytes32 _sender, bytes calldata _body) internal { + (, address user, string memory newUsername) = abi.decode(_body, (uint8, address, string)); + bytes32 newHash = _hashUsername(newUsername); + + // Check global reservation first (catches admin-burned names). + // changeUsernameCrossChain on UAR doesn't call back to claimUsernameLocal + // (unlike _register), so we must check reserved here explicitly. + if (s.reserved[newHash]) { + emit UsernameRejected(newHash, _origin, user); + bytes memory reject = abi.encode(MSG_REJECT_USERNAME, user, newUsername); + _dispatchToSatellite(s, _origin, _sender, reject); + return; + } + + // Atomic: try change on UAR (it burns old + claims new internally) + try s.accountRegistry.changeUsernameCrossChain(user, newUsername) { + s.reserved[newHash] = true; + emit UsernameChanged(bytes32(0), newHash, _origin, user); + + bytes memory confirm = abi.encode(MSG_CONFIRM_USERNAME, user, newUsername); + _dispatchToSatellite(s, _origin, _sender, confirm); + } catch { + emit UsernameRejected(newHash, _origin, user); + + bytes memory reject = abi.encode(MSG_REJECT_USERNAME, user, newUsername); + _dispatchToSatellite(s, _origin, _sender, reject); + } + } + + /*══════════════════ Internal: Org Name Handlers ══════════════════*/ + + function _handleClaimOrgName(Layout storage s, uint32 _origin, bytes32 _sender, bytes calldata _body) internal { + (, string memory orgName) = abi.decode(_body, (uint8, string)); + bytes32 nameHash = _hashUsername(orgName); + + if (s.reservedOrgNames[nameHash]) { + emit OrgNameRejected(nameHash, _origin); + bytes memory reject = abi.encode(MSG_REJECT_ORG_NAME, orgName); + _dispatchToSatellite(s, _origin, _sender, reject); + } else { + s.reservedOrgNames[nameHash] = true; + emit OrgNameReserved(nameHash, _origin); + bytes memory confirm = abi.encode(MSG_CONFIRM_ORG_NAME, orgName); + _dispatchToSatellite(s, _origin, _sender, confirm); + } + } + + function _handleReleaseOrgName(Layout storage s, uint32 _origin, bytes calldata _body) internal { + (, bytes32 nameHash) = abi.decode(_body, (uint8, bytes32)); + delete s.reservedOrgNames[nameHash]; + emit OrgNameReleased(nameHash, _origin); + } + + /*══════════════════ Internal: Helpers ══════════════════*/ + + function _isRegisteredSatellite(Layout storage s, uint32 domain, bytes32 sender) internal view returns (bool) { + uint256 len = s.satellites.length; + for (uint256 i; i < len;) { + if (s.satellites[i].active && s.satellites[i].domain == domain && s.satellites[i].satellite == sender) { + return true; + } + unchecked { + ++i; + } + } + return false; + } + + function _dispatchToSatellite(Layout storage s, uint32 domain, bytes32 satellite, bytes memory payload) internal { + uint256 fee = s.returnFee; + if (fee > 0 && address(this).balance < fee) revert InsufficientBalance(); + s.mailbox.dispatch{value: fee}(domain, satellite, payload); + } + + function _hashUsername(string memory username) internal pure returns (bytes32) { + bytes memory b = bytes(username); + for (uint256 i; i < b.length; ++i) { + uint8 c = uint8(b[i]); + if (c >= 65 && c <= 90) b[i] = bytes1(c + 32); + } + return keccak256(b); + } +} diff --git a/src/crosschain/PoaManagerHub.sol b/src/crosschain/PoaManagerHub.sol index e7dc426..69aba41 100644 --- a/src/crosschain/PoaManagerHub.sol +++ b/src/crosschain/PoaManagerHub.sol @@ -1,16 +1,18 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; import {IMailbox} from "./interfaces/IHyperlane.sol"; import {PoaManager} from "../PoaManager.sol"; /// @title PoaManagerHub /// @notice Home-chain wrapper around PoaManager that propagates beacon upgrades /// to satellite chains via Hyperlane. -/// @dev Deploy on the home chain, then transfer PoaManager ownership to this contract. -/// All admin calls (addContractType, upgradeBeacon) go through the Hub. -contract PoaManagerHub is Ownable(msg.sender) { +/// @dev Deploy behind a BeaconProxy on the home chain, then transfer PoaManager +/// ownership to this proxy. All admin calls (addContractType, upgradeBeacon) +/// go through the Hub. +contract PoaManagerHub is Initializable, OwnableUpgradeable { /*──────────── Types ───────────*/ struct SatelliteConfig { uint32 domain; // Hyperlane domain ID @@ -23,13 +25,23 @@ contract PoaManagerHub is Ownable(msg.sender) { uint8 internal constant MSG_UPGRADE_BEACON = 0x01; uint8 internal constant MSG_ADD_CONTRACT_TYPE = 0x02; - /*──────────── Immutables ──────────*/ - PoaManager public immutable poaManager; - IMailbox public immutable mailbox; + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.poamanagerhub.storage + struct Layout { + PoaManager poaManager; + IMailbox mailbox; + SatelliteConfig[] satellites; + bool paused; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.poamanagerhub.storage"); - /*──────────── Storage ─────────────*/ - SatelliteConfig[] public satellites; - bool public paused; + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } /*──────────── Errors ──────────────*/ error IsPaused(); @@ -51,10 +63,18 @@ contract PoaManagerHub is Ownable(msg.sender) { event PauseSet(bool paused); /*──────────── Constructor ─────────*/ - constructor(address _poaManager, address _mailbox) { - if (_poaManager == address(0) || _mailbox == address(0)) revert ZeroAddress(); - poaManager = PoaManager(_poaManager); - mailbox = IMailbox(_mailbox); + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + function initialize(address owner, address _poaManager, address _mailbox) external initializer { + if (owner == address(0) || _poaManager == address(0) || _mailbox == address(0)) revert ZeroAddress(); + __Ownable_init(owner); + Layout storage s = _layout(); + s.poaManager = PoaManager(_poaManager); + s.mailbox = IMailbox(_mailbox); } /*══════════════════ Upgrade Functions ══════════════════*/ @@ -66,21 +86,22 @@ contract PoaManagerHub is Ownable(msg.sender) { payable onlyOwner { - if (paused) revert IsPaused(); + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); uint256 preBalance = address(this).balance - msg.value; // 1. Upgrade locally (validates impl, updates registry, upgrades beacon) - poaManager.upgradeBeacon(typeName, newImpl, version); + s.poaManager.upgradeBeacon(typeName, newImpl, version); // 2. Dispatch to all active satellites bytes memory payload = abi.encode(MSG_UPGRADE_BEACON, typeName, newImpl, version); bytes32 typeId = keccak256(bytes(typeName)); - uint256 feePerSatellite = _feePerActiveSatellite(); - uint256 len = satellites.length; + uint256 feePerSatellite = _feePerActiveSatellite(s); + uint256 len = s.satellites.length; for (uint256 i; i < len;) { - SatelliteConfig storage sat = satellites[i]; + SatelliteConfig storage sat = s.satellites[i]; if (sat.active) { - bytes32 msgId = mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); + bytes32 msgId = s.mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); emit CrossChainUpgradeDispatched(typeId, newImpl, version, sat.domain, msgId); } unchecked { @@ -93,33 +114,34 @@ contract PoaManagerHub is Ownable(msg.sender) { /// @notice Upgrade a beacon on the home chain only (no cross-chain propagation). function upgradeBeaconLocal(string calldata typeName, address newImpl, string calldata version) external onlyOwner { - poaManager.upgradeBeacon(typeName, newImpl, version); + _layout().poaManager.upgradeBeacon(typeName, newImpl, version); } /*══════════════════ Contract Type Functions ══════════════════*/ /// @notice Register a new contract type on the home chain only. function addContractType(string calldata typeName, address impl) external onlyOwner { - poaManager.addContractType(typeName, impl); + _layout().poaManager.addContractType(typeName, impl); } /// @notice Register a new contract type on the home chain AND propagate to satellites. /// @dev Satellites must have the implementation already deployed at `impl`. /// Send enough ETH to cover Hyperlane protocol fees for all active satellites. function addContractTypeCrossChain(string calldata typeName, address impl) external payable onlyOwner { - if (paused) revert IsPaused(); + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); uint256 preBalance = address(this).balance - msg.value; - poaManager.addContractType(typeName, impl); + s.poaManager.addContractType(typeName, impl); bytes memory payload = abi.encode(MSG_ADD_CONTRACT_TYPE, typeName, impl); bytes32 typeId = keccak256(bytes(typeName)); - uint256 feePerSatellite = _feePerActiveSatellite(); - uint256 len = satellites.length; + uint256 feePerSatellite = _feePerActiveSatellite(s); + uint256 len = s.satellites.length; for (uint256 i; i < len;) { - SatelliteConfig storage sat = satellites[i]; + SatelliteConfig storage sat = s.satellites[i]; if (sat.active) { - bytes32 msgId = mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); + bytes32 msgId = s.mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); emit CrossChainAddTypeDispatched(typeId, typeName, sat.domain, msgId); } unchecked { @@ -136,25 +158,26 @@ contract PoaManagerHub is Ownable(msg.sender) { /// @dev Governance (Executor → Hub → PM) can use this to call admin functions /// on sub-contracts that gate on `msg.sender == poaManager`. function adminCall(address target, bytes calldata data) external onlyOwner returns (bytes memory) { - return poaManager.adminCall(target, data); + return _layout().poaManager.adminCall(target, data); } /*══════════════════ Registry Passthrough ══════════════════*/ /// @notice Update the ImplementationRegistry on the local PoaManager. function updateImplRegistry(address registryAddr) external onlyOwner { - poaManager.updateImplRegistry(registryAddr); + _layout().poaManager.updateImplRegistry(registryAddr); } /*══════════════════ Satellite Management ══════════════════*/ function registerSatellite(uint32 domain, address satellite) external onlyOwner { if (satellite == address(0)) revert ZeroAddress(); + Layout storage s = _layout(); // Reject duplicate active domains — prevents double-dispatch and fee burn - uint256 len = satellites.length; + uint256 len = s.satellites.length; for (uint256 i; i < len;) { - if (satellites[i].domain == domain && satellites[i].active) { + if (s.satellites[i].domain == domain && s.satellites[i].active) { revert DuplicateDomain(domain); } unchecked { @@ -162,20 +185,40 @@ contract PoaManagerHub is Ownable(msg.sender) { } } - satellites.push( - SatelliteConfig({domain: domain, satellite: bytes32(uint256(uint160(satellite))), active: true}) - ); + s.satellites + .push(SatelliteConfig({domain: domain, satellite: bytes32(uint256(uint160(satellite))), active: true})); emit SatelliteRegistered(domain, satellite); } function removeSatellite(uint256 index) external onlyOwner { - uint32 domain = satellites[index].domain; - satellites[index].active = false; + Layout storage s = _layout(); + uint32 domain = s.satellites[index].domain; + s.satellites[index].active = false; emit SatelliteRemoved(domain); } + /*══════════════════ Public Getters ══════════════════*/ + + function poaManager() external view returns (PoaManager) { + return _layout().poaManager; + } + + function mailbox() external view returns (IMailbox) { + return _layout().mailbox; + } + + function satellites(uint256 index) external view returns (uint32 domain, bytes32 satellite, bool active) { + Layout storage s = _layout(); + SatelliteConfig storage sat = s.satellites[index]; + return (sat.domain, sat.satellite, sat.active); + } + + function paused() external view returns (bool) { + return _layout().paused; + } + function satelliteCount() external view returns (uint256) { - return satellites.length; + return _layout().satellites.length; } /*══════════════════ Ownership Safety ══════════════════*/ @@ -188,13 +231,13 @@ contract PoaManagerHub is Ownable(msg.sender) { /// @notice Transfer PoaManager ownership (e.g. to a replacement Hub). function transferPoaManagerOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert ZeroAddress(); - poaManager.transferOwnership(newOwner); + _layout().poaManager.transferOwnership(newOwner); } /*══════════════════ Emergency ══════════════════*/ function setPaused(bool _paused) external onlyOwner { - paused = _paused; + _layout().paused = _paused; emit PauseSet(_paused); } @@ -210,11 +253,11 @@ contract PoaManagerHub is Ownable(msg.sender) { /// @dev Computes the fee to send per active satellite by dividing msg.value evenly. /// Reverts if ETH is sent but there are no active satellites (would be lost). - function _feePerActiveSatellite() internal view returns (uint256) { - uint256 len = satellites.length; + function _feePerActiveSatellite(Layout storage s) internal view returns (uint256) { + uint256 len = s.satellites.length; uint256 activeCount; for (uint256 i; i < len;) { - if (satellites[i].active) { + if (s.satellites[i].active) { unchecked { ++activeCount; } diff --git a/src/crosschain/PoaManagerSatellite.sol b/src/crosschain/PoaManagerSatellite.sol index ab7538d..2f5e568 100644 --- a/src/crosschain/PoaManagerSatellite.sol +++ b/src/crosschain/PoaManagerSatellite.sol @@ -1,27 +1,38 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; import {IMessageRecipient} from "./interfaces/IHyperlane.sol"; import {PoaManager} from "../PoaManager.sol"; /// @title PoaManagerSatellite /// @notice Remote-chain receiver that applies beacon upgrades dispatched by the Hub. -/// @dev Deploy on each satellite chain. Owns a local PoaManager instance. +/// @dev Deploy behind a BeaconProxy on each satellite chain. Owns a local PoaManager instance. /// Only accepts Hyperlane messages from the Hub on the home chain. -contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { +contract PoaManagerSatellite is Initializable, OwnableUpgradeable, IMessageRecipient { /*──────────── Constants ───────────*/ uint8 internal constant MSG_UPGRADE_BEACON = 0x01; uint8 internal constant MSG_ADD_CONTRACT_TYPE = 0x02; - /*──────────── Immutables ──────────*/ - PoaManager public immutable poaManager; - address public immutable mailbox; - uint32 public immutable hubDomain; - bytes32 public immutable hubAddress; + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.poamanagersatellite.storage + struct Layout { + PoaManager poaManager; + address mailbox; + uint32 hubDomain; + bytes32 hubAddress; + bool paused; + } - /*──────────── Storage ─────────────*/ - bool public paused; + bytes32 private constant _STORAGE_SLOT = keccak256("poa.poamanagersatellite.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } /*──────────── Errors ──────────────*/ error UnauthorizedMailbox(); @@ -38,14 +49,25 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { event PauseSet(bool paused); /*──────────── Constructor ─────────*/ - constructor(address _poaManager, address _mailbox, uint32 _hubDomain, address _hubAddress) { - if (_poaManager == address(0) || _mailbox == address(0) || _hubAddress == address(0)) { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + function initialize(address owner, address _poaManager, address _mailbox, uint32 _hubDomain, address _hubAddress) + external + initializer + { + if (owner == address(0) || _poaManager == address(0) || _mailbox == address(0) || _hubAddress == address(0)) { revert ZeroAddress(); } - poaManager = PoaManager(_poaManager); - mailbox = _mailbox; - hubDomain = _hubDomain; - hubAddress = bytes32(uint256(uint160(_hubAddress))); + __Ownable_init(owner); + Layout storage s = _layout(); + s.poaManager = PoaManager(_poaManager); + s.mailbox = _mailbox; + s.hubDomain = _hubDomain; + s.hubAddress = bytes32(uint256(uint160(_hubAddress))); } /*══════════════════ Hyperlane Receiver ══════════════════*/ @@ -53,10 +75,11 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { /// @notice Called by the Hyperlane Mailbox when a message arrives from the Hub. /// @dev Validates origin chain, sender address, and mailbox caller. function handle(uint32 _origin, bytes32 _sender, bytes calldata _body) external override { - if (msg.sender != mailbox) revert UnauthorizedMailbox(); - if (_origin != hubDomain) revert UnauthorizedOrigin(); - if (_sender != hubAddress) revert UnauthorizedSender(); - if (paused) revert IsPaused(); + Layout storage s = _layout(); + if (msg.sender != s.mailbox) revert UnauthorizedMailbox(); + if (_origin != s.hubDomain) revert UnauthorizedOrigin(); + if (_sender != s.hubAddress) revert UnauthorizedSender(); + if (s.paused) revert IsPaused(); uint8 msgType = abi.decode(_body[:32], (uint8)); @@ -64,13 +87,13 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { (, string memory typeName, address newImpl, string memory version) = abi.decode(_body, (uint8, string, address, string)); - poaManager.upgradeBeacon(typeName, newImpl, version); + s.poaManager.upgradeBeacon(typeName, newImpl, version); emit UpgradeReceived(keccak256(bytes(typeName)), newImpl, version, _origin); } else if (msgType == MSG_ADD_CONTRACT_TYPE) { (, string memory typeName, address impl) = abi.decode(_body, (uint8, string, address)); - poaManager.addContractType(typeName, impl); + s.poaManager.addContractType(typeName, impl); emit ContractTypeReceived(keccak256(bytes(typeName)), typeName, impl, _origin); } else { @@ -78,6 +101,28 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { } } + /*══════════════════ Public Getters ══════════════════*/ + + function poaManager() external view returns (PoaManager) { + return _layout().poaManager; + } + + function mailbox() external view returns (address) { + return _layout().mailbox; + } + + function hubDomain() external view returns (uint32) { + return _layout().hubDomain; + } + + function hubAddress() external view returns (bytes32) { + return _layout().hubAddress; + } + + function paused() external view returns (bool) { + return _layout().paused; + } + /*══════════════════ Ownership Safety ══════════════════*/ /// @dev Ownership cannot be renounced — losing it bricks the satellite permanently. @@ -88,7 +133,7 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { /*══════════════════ Pause ══════════════════*/ function setPaused(bool _paused) external onlyOwner { - paused = _paused; + _layout().paused = _paused; emit PauseSet(_paused); } @@ -98,7 +143,7 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { /// @dev Owner can use this to call admin functions on sub-contracts /// that gate on `msg.sender == poaManager`. function adminCall(address target, bytes calldata data) external onlyOwner returns (bytes memory) { - return poaManager.adminCall(target, data); + return _layout().poaManager.adminCall(target, data); } /*══════════════════ Emergency / Direct Admin ══════════════════*/ @@ -108,22 +153,22 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { external onlyOwner { - poaManager.upgradeBeacon(typeName, newImpl, version); + _layout().poaManager.upgradeBeacon(typeName, newImpl, version); } /// @notice Register a new contract type locally. function addContractType(string calldata typeName, address impl) external onlyOwner { - poaManager.addContractType(typeName, impl); + _layout().poaManager.addContractType(typeName, impl); } /// @notice Update the ImplementationRegistry on the local PoaManager. function updateImplRegistry(address registryAddr) external onlyOwner { - poaManager.updateImplRegistry(registryAddr); + _layout().poaManager.updateImplRegistry(registryAddr); } /// @notice Transfer PoaManager ownership (e.g. to a replacement satellite). function transferPoaManagerOwnership(address newOwner) external onlyOwner { if (newOwner == address(0)) revert ZeroAddress(); - poaManager.transferOwnership(newOwner); + _layout().poaManager.transferOwnership(newOwner); } } diff --git a/src/crosschain/RegistryRelay.sol b/src/crosschain/RegistryRelay.sol new file mode 100644 index 0000000..d50fe52 --- /dev/null +++ b/src/crosschain/RegistryRelay.sol @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; +import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import {IMailbox, IMessageRecipient} from "./interfaces/IHyperlane.sol"; + +/// @title RegistryRelay +/// @notice Satellite-chain contract that relays username registration requests +/// to the NameRegistryHub on Arbitrum via Hyperlane, and receives +/// confirmations/rejections back. +/// @dev Deploy behind a BeaconProxy. Stores confirmed usernames in a local cache +/// for on-chain reads (e.g. QuickJoin checking if a user has a username). +contract RegistryRelay is Initializable, OwnableUpgradeable, IMessageRecipient { + /*──────────── Constants ───────────*/ + uint8 internal constant MSG_CLAIM_USERNAME = 0x01; + uint8 internal constant MSG_CONFIRM_USERNAME = 0x02; + uint8 internal constant MSG_REJECT_USERNAME = 0x03; + uint8 internal constant MSG_BURN_USERNAME = 0x04; + uint8 internal constant MSG_CHANGE_USERNAME = 0x05; + uint8 internal constant MSG_CLAIM_ORG_NAME = 0x06; + uint8 internal constant MSG_CONFIRM_ORG_NAME = 0x07; + uint8 internal constant MSG_REJECT_ORG_NAME = 0x08; + uint8 internal constant MSG_RELEASE_ORG_NAME = 0x09; + + uint256 private constant MAX_LEN = 64; + uint256 private constant MAX_ORG_NAME_LEN = 256; + + /*──────────── EIP-712 Constants ───────────*/ + bytes32 private constant _DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant _NAME_HASH = keccak256("RegistryRelay"); + bytes32 private constant _VERSION_HASH = keccak256("1"); + bytes32 private constant _REGISTER_TYPEHASH = + keccak256("RegisterAccount(address user,string username,uint256 nonce,uint256 deadline)"); + + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.registryrelay.storage + struct Layout { + IMailbox mailbox; + uint32 hubDomain; + bytes32 hubAddress; + bool paused; + mapping(address => string) confirmedUsernames; + mapping(bytes32 => address) confirmedOwners; + mapping(address => uint256) nonces; + mapping(bytes32 => bool) confirmedOrgNames; + mapping(address => bool) authorizedCallers; + uint256 dispatchFee; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.registryrelay.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /*──────────── Errors ──────────────*/ + error UnauthorizedMailbox(); + error UnauthorizedOrigin(); + error UnauthorizedSender(); + error UnknownMessageType(); + error ZeroAddress(); + error IsPaused(); + error CannotRenounce(); + error UsernameEmpty(); + error UsernameTooLong(); + error InvalidChars(); + error SignatureExpired(); + error InvalidNonce(); + error InvalidSigner(); + error OrgNameEmpty(); + error OrgNameTooLong(); + error UnauthorizedCaller(); + error InsufficientBalance(); + error TransferFailed(); + + /*──────────── Events ──────────────*/ + event ClaimDispatched(address indexed user, string username, bytes32 messageId); + event UsernameConfirmed(address indexed user, string username); + event UsernameRejected(address indexed user, string username); + event BurnDispatched(address indexed user, bytes32 messageId); + event ChangeDispatched(address indexed user, string newUsername, bytes32 messageId); + event PauseSet(bool paused); + event OrgNameClaimDispatched(string orgName, bytes32 messageId); + event OrgNameConfirmed(string orgName); + event OrgNameRejected(string orgName); + event AuthorizedCallerSet(address indexed caller, bool authorized); + event OrgNameReleaseDispatched(bytes32 indexed nameHash, bytes32 messageId); + event DispatchFeeSet(uint256 fee); + + /*──────────── Constructor ─────────*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + function initialize(address owner, address _mailbox, uint32 _hubDomain, address _hubAddress) external initializer { + if (owner == address(0) || _mailbox == address(0) || _hubAddress == address(0)) revert ZeroAddress(); + __Ownable_init(owner); + Layout storage s = _layout(); + s.mailbox = IMailbox(_mailbox); + s.hubDomain = _hubDomain; + s.hubAddress = bytes32(uint256(uint160(_hubAddress))); + } + + /*══════════════════ Registration ══════════════════*/ + + /// @notice Register a username for `user` using their EIP-712 signature. + /// @dev Validates format locally, verifies signature, then relays to hub. + /// The caller (org/relayer) pays gas + Hyperlane fee via msg.value. + function registerAccount( + address user, + string calldata username, + uint256 deadline, + uint256 nonce, + bytes calldata signature + ) external payable { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + if (block.timestamp > deadline) revert SignatureExpired(); + if (nonce != s.nonces[user]) revert InvalidNonce(); + + // Validate username format locally (saves gas on invalid names) + _validateUsername(username); + + // Verify EIP-712 signature proves user consent + bytes32 structHash = + keccak256(abi.encode(_REGISTER_TYPEHASH, user, keccak256(bytes(username)), nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + address signer = ECDSA.recover(digest, signature); + if (signer != user) revert InvalidSigner(); + + s.nonces[user] = nonce + 1; + + // Dispatch claim to hub + bytes memory payload = abi.encode(MSG_CLAIM_USERNAME, user, username); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit ClaimDispatched(user, username, msgId); + } + + /// @notice Direct registration — caller registers their own username. + function registerAccountDirect(string calldata username) external payable { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + _validateUsername(username); + + bytes memory payload = abi.encode(MSG_CLAIM_USERNAME, msg.sender, username); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit ClaimDispatched(msg.sender, username, msgId); + } + + /// @notice Register a username on behalf of a user. Authorized callers only. + /// @dev Used by SatelliteOnboardingHelper to register users in a single tx. + function registerAccountForUser(address user, string calldata username) external payable { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + if (!s.authorizedCallers[msg.sender]) revert UnauthorizedCaller(); + if (user == address(0)) revert ZeroAddress(); + _validateUsername(username); + + bytes memory payload = abi.encode(MSG_CLAIM_USERNAME, user, username); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit ClaimDispatched(user, username, msgId); + } + + /// @notice Change username — caller changes their own username. + function changeUsername(string calldata newUsername) external payable { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + _validateUsername(newUsername); + + bytes memory payload = abi.encode(MSG_CHANGE_USERNAME, msg.sender, newUsername); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit ChangeDispatched(msg.sender, newUsername, msgId); + } + + /// @notice Delete account — caller deletes their username (permanently burned). + function deleteAccount() external payable { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + + // Clear local cache + string memory oldName = s.confirmedUsernames[msg.sender]; + if (bytes(oldName).length > 0) { + bytes32 oldHash = _hashUsername(oldName); + delete s.confirmedUsernames[msg.sender]; + delete s.confirmedOwners[oldHash]; + } + + bytes memory payload = abi.encode(MSG_BURN_USERNAME, msg.sender); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit BurnDispatched(msg.sender, msgId); + } + + /*══════════════════ Org Name Registration ══════════════════*/ + + /// @notice Dispatch a cross-chain org name claim to the hub. + /// @dev Only owner (governance) can claim org names to prevent squatting. + function claimOrgName(string calldata orgName) external payable onlyOwner { + Layout storage s = _layout(); + if (s.paused) revert IsPaused(); + _validateOrgName(orgName); + + bytes memory payload = abi.encode(MSG_CLAIM_ORG_NAME, orgName); + bytes32 msgId = s.mailbox.dispatch{value: msg.value}(s.hubDomain, s.hubAddress, payload); + + emit OrgNameClaimDispatched(orgName, msgId); + } + + /// @notice Dispatch an optimistic org name claim to the hub. + /// @dev Uses pre-funded relay balance for Hyperlane fee. Authorized callers only. + /// Called by NameClaimAdapter during org deployment. + function dispatchOrgNameClaim(string calldata orgName) external { + if (!_layout().authorizedCallers[msg.sender]) revert UnauthorizedCaller(); + _validateOrgName(orgName); + + bytes32 msgId = _dispatchPreFunded(abi.encode(MSG_CLAIM_ORG_NAME, orgName)); + emit OrgNameClaimDispatched(orgName, msgId); + } + + /// @notice Release a confirmed org name — clears local cache and dispatches to hub. + /// @dev Uses pre-funded relay balance for Hyperlane fee. Authorized callers only. + function dispatchOrgNameRelease(bytes32 nameHash) external { + Layout storage s = _layout(); + if (!s.authorizedCallers[msg.sender]) revert UnauthorizedCaller(); + + delete s.confirmedOrgNames[nameHash]; + + bytes32 msgId = _dispatchPreFunded(abi.encode(MSG_RELEASE_ORG_NAME, nameHash)); + emit OrgNameReleaseDispatched(nameHash, msgId); + } + + /*══════════════════ Hyperlane Receiver ══════════════════*/ + + /// @notice Receives confirm/reject from the NameRegistryHub. + function handle(uint32 _origin, bytes32 _sender, bytes calldata _body) external override { + Layout storage s = _layout(); + if (msg.sender != address(s.mailbox)) revert UnauthorizedMailbox(); + if (_origin != s.hubDomain) revert UnauthorizedOrigin(); + if (_sender != s.hubAddress) revert UnauthorizedSender(); + + uint8 msgType = abi.decode(_body[:32], (uint8)); + + if (msgType == MSG_CONFIRM_USERNAME) { + (, address user, string memory username) = abi.decode(_body, (uint8, address, string)); + + // Clear stale cache entry if user had a previous name (e.g. username change) + string memory oldName = s.confirmedUsernames[user]; + if (bytes(oldName).length > 0) { + delete s.confirmedOwners[_hashUsername(oldName)]; + } + + // Update local cache + bytes32 nameHash = _hashUsername(username); + s.confirmedUsernames[user] = username; + s.confirmedOwners[nameHash] = user; + + emit UsernameConfirmed(user, username); + } else if (msgType == MSG_REJECT_USERNAME) { + (, address user, string memory username) = abi.decode(_body, (uint8, address, string)); + + emit UsernameRejected(user, username); + } else if (msgType == MSG_CONFIRM_ORG_NAME) { + (, string memory orgName) = abi.decode(_body, (uint8, string)); + s.confirmedOrgNames[_hashUsername(orgName)] = true; + emit OrgNameConfirmed(orgName); + } else if (msgType == MSG_REJECT_ORG_NAME) { + (, string memory orgName) = abi.decode(_body, (uint8, string)); + emit OrgNameRejected(orgName); + } else { + revert UnknownMessageType(); + } + } + + /*══════════════════ Public Getters ══════════════════*/ + + function mailbox() external view returns (IMailbox) { + return _layout().mailbox; + } + + function hubDomain() external view returns (uint32) { + return _layout().hubDomain; + } + + function hubAddress() external view returns (bytes32) { + return _layout().hubAddress; + } + + function paused() external view returns (bool) { + return _layout().paused; + } + + function nonces(address user) external view returns (uint256) { + return _layout().nonces[user]; + } + + function confirmedOrgNames(bytes32 nameHash) external view returns (bool) { + return _layout().confirmedOrgNames[nameHash]; + } + + function authorizedCallers(address caller) external view returns (bool) { + return _layout().authorizedCallers[caller]; + } + + function dispatchFee() external view returns (uint256) { + return _layout().dispatchFee; + } + + /*══════════════════ View Helpers ══════════════════*/ + + /// @notice Get a user's confirmed username (from local cache). + function getUsername(address user) external view returns (string memory) { + return _layout().confirmedUsernames[user]; + } + + /// @notice Check if a name is taken in the local cache. + /// @dev This is NOT authoritative — the hub is the source of truth. + /// A name may be taken on the hub but not yet synced to this cache. + function getAddressOfUsername(string calldata name) external view returns (address) { + return _layout().confirmedOwners[_hashUsername(name)]; + } + + /// @notice Check if an org name is confirmed in the local cache. + /// @dev This is NOT authoritative — the hub is the source of truth. + function isOrgNameConfirmed(string calldata orgName) external view returns (bool) { + return _layout().confirmedOrgNames[_hashUsername(orgName)]; + } + + /// @notice EIP-712 domain separator for this relay. + // solhint-disable-next-line func-name-mixedcase + function DOMAIN_SEPARATOR() external view returns (bytes32) { + return _domainSeparator(); + } + + /*══════════════════ Admin ══════════════════*/ + + function setPaused(bool _paused) external onlyOwner { + _layout().paused = _paused; + emit PauseSet(_paused); + } + + function setAuthorizedCaller(address caller, bool authorized) external onlyOwner { + if (caller == address(0)) revert ZeroAddress(); + _layout().authorizedCallers[caller] = authorized; + emit AuthorizedCallerSet(caller, authorized); + } + + function setDispatchFee(uint256 _fee) external onlyOwner { + _layout().dispatchFee = _fee; + emit DispatchFeeSet(_fee); + } + + function withdrawETH(address payable to) external onlyOwner { + if (to == address(0)) revert ZeroAddress(); + uint256 balance = address(this).balance; + (bool ok,) = to.call{value: balance}(""); + if (!ok) revert TransferFailed(); + } + + /// @dev Ownership cannot be renounced. + function renounceOwnership() public pure override { + revert CannotRenounce(); + } + + /// @dev Accept ETH for pre-funded dispatches (claims + releases). + receive() external payable {} + + /*══════════════════ Internal ══════════════════*/ + + /// @dev Dispatch a message to the hub using pre-funded relay balance. + function _dispatchPreFunded(bytes memory payload) internal returns (bytes32) { + Layout storage s = _layout(); + uint256 fee = s.dispatchFee; + if (fee > 0 && address(this).balance < fee) revert InsufficientBalance(); + return s.mailbox.dispatch{value: fee}(s.hubDomain, s.hubAddress, payload); + } + + function _domainSeparator() internal view returns (bytes32) { + return keccak256(abi.encode(_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this))); + } + + function _validateUsername(string calldata raw) internal pure { + uint256 len = bytes(raw).length; + if (len == 0) revert UsernameEmpty(); + if (len > MAX_LEN) revert UsernameTooLong(); + + bytes memory b = bytes(raw); + for (uint256 i; i < len;) { + uint8 c = uint8(b[i]); + if (c >= 65 && c <= 90) c += 32; // to lowercase + if (!((c >= 97 && c <= 122) || (c >= 48 && c <= 57) || (c == 95) || (c == 45))) { + revert InvalidChars(); + } + unchecked { + ++i; + } + } + } + + function _validateOrgName(string calldata raw) internal pure { + uint256 len = bytes(raw).length; + if (len == 0) revert OrgNameEmpty(); + if (len > MAX_ORG_NAME_LEN) revert OrgNameTooLong(); + } + + function _hashUsername(string memory username) internal pure returns (bytes32) { + bytes memory b = bytes(username); + for (uint256 i; i < b.length; ++i) { + uint8 c = uint8(b[i]); + if (c >= 65 && c <= 90) b[i] = bytes1(c + 32); + } + return keccak256(b); + } +} diff --git a/src/crosschain/SatelliteOnboardingHelper.sol b/src/crosschain/SatelliteOnboardingHelper.sol new file mode 100644 index 0000000..80c4190 --- /dev/null +++ b/src/crosschain/SatelliteOnboardingHelper.sol @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/access/OwnableUpgradeable.sol"; + +/// @notice Minimal interface for RegistryRelay's onboarding operations. +interface IRegistryRelayOnboarding { + function registerAccountForUser(address user, string calldata username) external payable; + function registerAccount( + address user, + string calldata username, + uint256 deadline, + uint256 nonce, + bytes calldata signature + ) external payable; + function getUsername(address user) external view returns (string memory); +} + +/// @notice Minimal interface for QuickJoin's satellite onboarding paths. +interface IQuickJoinSatellite { + function quickJoinNoUserMasterDeploy(address newUser) external; + function quickJoinForUser(address user) external; +} + +/// @notice Minimal interface for passkey account factory. +interface IPasskeyFactory { + function createAccount(bytes32 credentialId, bytes32 pubKeyX, bytes32 pubKeyY, uint256 salt) + external + returns (address account); +} + +/// @title SatelliteOnboardingHelper +/// @notice Per-org satellite contract that provides single-tx optimistic onboarding. +/// @dev On satellites, username registration is async (Hyperlane round-trip). +/// This helper uses an optimistic pattern: dispatch the username claim AND +/// join the org immediately in the same tx. The username confirms in the +/// background via Hyperlane. Frontend pre-tx checks prevent collisions. +/// +/// Set as QuickJoin's `masterDeployAddress` so it can call join functions. +/// Set as RegistryRelay's authorized caller so it can register on behalf of users. +/// Deploy behind a BeaconProxy. +contract SatelliteOnboardingHelper is Initializable, OwnableUpgradeable { + /*──────────── Passkey Enrollment Struct ──────────*/ + struct PasskeyEnrollment { + bytes32 credentialId; + bytes32 publicKeyX; + bytes32 publicKeyY; + uint256 salt; + } + + /*──────────── ERC-7201 Storage ──────────*/ + /// @custom:storage-location erc7201:poa.satelliteonboardinghelper.storage + struct Layout { + IRegistryRelayOnboarding relay; + IQuickJoinSatellite quickJoin; + IPasskeyFactory passkeyFactory; + } + + bytes32 private constant _STORAGE_SLOT = keccak256("poa.satelliteonboardinghelper.storage"); + + function _layout() private pure returns (Layout storage s) { + bytes32 slot = _STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + /*──────────── Errors ──────────*/ + error ZeroAddress(); + error CannotRenounce(); + error NoUsername(); + error PasskeyFactoryNotSet(); + + /*──────────── Events ──────────*/ + event RegisterAndJoined(address indexed user, string username); + event RegisterAndJoinedWithPasskey(address indexed account, bytes32 indexed credentialId, string username); + event JoinCompleted(address indexed user); + event JoinCompletedWithPasskey(address indexed account, bytes32 indexed credentialId); + + /*──────────── Constructor ─────────*/ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /*──────────── Initializer ─────────*/ + /// @param _passkeyFactory Can be address(0) if passkey support not needed for this org. + function initialize(address owner, address _relay, address _quickJoin, address _passkeyFactory) + external + initializer + { + if (owner == address(0) || _relay == address(0) || _quickJoin == address(0)) revert ZeroAddress(); + __Ownable_init(owner); + Layout storage s = _layout(); + s.relay = IRegistryRelayOnboarding(_relay); + s.quickJoin = IQuickJoinSatellite(_quickJoin); + if (_passkeyFactory != address(0)) { + s.passkeyFactory = IPasskeyFactory(_passkeyFactory); + } + } + + /*══════════════════ Optimistic Onboarding (single-tx) ══════════════════*/ + + /// @notice Register username + join org in one tx (EOA, user calls directly). + /// @dev Dispatches username claim optimistically via relay, then joins immediately. + /// Caller pays Hyperlane fee via msg.value. + function registerAndJoin(string calldata username) external payable { + Layout storage s = _layout(); + s.relay.registerAccountForUser{value: msg.value}(msg.sender, username); + s.quickJoin.quickJoinNoUserMasterDeploy(msg.sender); + emit RegisterAndJoined(msg.sender, username); + } + + /// @notice Register username + join org in one tx (sponsored by relayer/backend). + /// @dev Uses RegistryRelay's EIP-712 signature verification — no new relay + /// function needed since the sig proves user consent. + function registerAndJoinSponsored( + address user, + string calldata username, + uint256 deadline, + uint256 nonce, + bytes calldata signature + ) external payable { + Layout storage s = _layout(); + s.relay.registerAccount{value: msg.value}(user, username, deadline, nonce, signature); + s.quickJoin.quickJoinNoUserMasterDeploy(user); + emit RegisterAndJoined(user, username); + } + + /// @notice Register username + create passkey account + join org in one tx. + /// @dev Creates passkey account via factory, dispatches username claim for that + /// account address, then joins immediately. + function registerAndJoinWithPasskey(PasskeyEnrollment calldata passkey, string calldata username) + external + payable + returns (address account) + { + Layout storage s = _layout(); + if (address(s.passkeyFactory) == address(0)) revert PasskeyFactoryNotSet(); + + account = + s.passkeyFactory.createAccount(passkey.credentialId, passkey.publicKeyX, passkey.publicKeyY, passkey.salt); + s.relay.registerAccountForUser{value: msg.value}(account, username); + s.quickJoin.quickJoinNoUserMasterDeploy(account); + + emit RegisterAndJoinedWithPasskey(account, passkey.credentialId, username); + } + + /*══════════════════ Non-Optimistic Onboarding ══════════════════*/ + + /// @notice Join org for users who already have a confirmed username. + /// @dev No Hyperlane dispatch — just checks relay cache and joins. + function quickJoinWithUser() external { + Layout storage s = _layout(); + string memory existing = s.relay.getUsername(msg.sender); + if (bytes(existing).length == 0) revert NoUsername(); + + s.quickJoin.quickJoinForUser(msg.sender); + emit JoinCompleted(msg.sender); + } + + /// @notice Join org for passkey users who already have a confirmed username. + /// @dev Creates/gets passkey account, checks relay cache, joins. + function quickJoinWithPasskey(PasskeyEnrollment calldata passkey) external returns (address account) { + Layout storage s = _layout(); + if (address(s.passkeyFactory) == address(0)) revert PasskeyFactoryNotSet(); + + account = + s.passkeyFactory.createAccount(passkey.credentialId, passkey.publicKeyX, passkey.publicKeyY, passkey.salt); + + string memory existing = s.relay.getUsername(account); + if (bytes(existing).length == 0) revert NoUsername(); + + s.quickJoin.quickJoinForUser(account); + emit JoinCompletedWithPasskey(account, passkey.credentialId); + } + + /*══════════════════ Admin ══════════════════*/ + + /// @dev Ownership cannot be renounced. + function renounceOwnership() public pure override { + revert CannotRenounce(); + } + + /*══════════════════ Public Getters ══════════════════*/ + + function relay() external view returns (IRegistryRelayOnboarding) { + return _layout().relay; + } + + function quickJoin() external view returns (IQuickJoinSatellite) { + return _layout().quickJoin; + } + + function passkeyFactory() external view returns (IPasskeyFactory) { + return _layout().passkeyFactory; + } +} diff --git a/test/CrossChainNameRegistry.t.sol b/test/CrossChainNameRegistry.t.sol new file mode 100644 index 0000000..37d2a96 --- /dev/null +++ b/test/CrossChainNameRegistry.t.sol @@ -0,0 +1,1304 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {NameRegistryHub} from "../src/crosschain/NameRegistryHub.sol"; +import {RegistryRelay} from "../src/crosschain/RegistryRelay.sol"; +import {UniversalAccountRegistry} from "../src/UniversalAccountRegistry.sol"; +import {OrgRegistry} from "../src/OrgRegistry.sol"; +import {StoringMailbox} from "./mocks/StoringMailbox.sol"; +import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +contract CrossChainNameRegistryTest is Test { + /*──────────── Constants ───────────*/ + uint32 constant HOME_DOMAIN = 42161; // Arbitrum + uint32 constant SAT_DOMAIN_A = 100; // Gnosis + uint32 constant SAT_DOMAIN_B = 8453; // Base + + /*──────────── Contracts ───────────*/ + // Home chain: uses StoringMailbox (no auto-delivery) so hub's return dispatches + // don't fail when crossing chain boundaries in tests. + StoringMailbox homeMailbox; + UniversalAccountRegistry uar; + NameRegistryHub hub; + + // Satellite mailboxes (StoringMailbox — no auto-delivery, avoids cross-chain sender mismatch) + StoringMailbox satMailboxA; + StoringMailbox satMailboxB; + RegistryRelay relayA; + RegistryRelay relayB; + + // Home chain: OrgRegistry for org name tests + OrgRegistry orgRegistry; + + /*──────────── Users ───────────*/ + uint256 aliceKey = 0xa11ce; + address alice = vm.addr(aliceKey); + uint256 bobKey = 0xb0b; + address bob = vm.addr(bobKey); + + /*──────────── Setup ───────────*/ + function setUp() public { + // Deploy home-chain UAR behind beacon proxy + UniversalAccountRegistry uarImpl = new UniversalAccountRegistry(); + UpgradeableBeacon uarBeacon = new UpgradeableBeacon(address(uarImpl), address(this)); + bytes memory initData = abi.encodeWithSignature("initialize(address)", address(this)); + uar = UniversalAccountRegistry(address(new BeaconProxy(address(uarBeacon), initData))); + + // Deploy home-chain mailbox (storing, no auto-delivery) + homeMailbox = new StoringMailbox(HOME_DOMAIN); + + // Deploy hub behind beacon proxy (upgradeable pattern) + NameRegistryHub hubImpl = new NameRegistryHub(); + UpgradeableBeacon hubBeacon = new UpgradeableBeacon(address(hubImpl), address(this)); + bytes memory hubInit = + abi.encodeCall(NameRegistryHub.initialize, (address(this), address(uar), address(homeMailbox))); + hub = NameRegistryHub(payable(address(new BeaconProxy(address(hubBeacon), hubInit)))); + + // Wire UAR to hub + uar.setNameRegistryHub(address(hub)); + + // Deploy satellite mailboxes (storing, no auto-delivery) + satMailboxA = new StoringMailbox(SAT_DOMAIN_A); + satMailboxB = new StoringMailbox(SAT_DOMAIN_B); + + // Deploy relays behind shared beacon proxy (upgradeable pattern) + RegistryRelay relayImpl = new RegistryRelay(); + UpgradeableBeacon relayBeacon = new UpgradeableBeacon(address(relayImpl), address(this)); + + bytes memory relayInitA = + abi.encodeCall(RegistryRelay.initialize, (address(this), address(satMailboxA), HOME_DOMAIN, address(hub))); + relayA = RegistryRelay(payable(address(new BeaconProxy(address(relayBeacon), relayInitA)))); + + bytes memory relayInitB = + abi.encodeCall(RegistryRelay.initialize, (address(this), address(satMailboxB), HOME_DOMAIN, address(hub))); + relayB = RegistryRelay(payable(address(new BeaconProxy(address(relayBeacon), relayInitB)))); + + // Register satellites on hub + hub.registerSatellite(SAT_DOMAIN_A, address(relayA)); + hub.registerSatellite(SAT_DOMAIN_B, address(relayB)); + + // Deploy OrgRegistry behind ERC1967 proxy (for org name tests) + OrgRegistry orgImpl = new OrgRegistry(); + bytes memory orgInit = abi.encodeCall(OrgRegistry.initialize, (address(this), address(1))); // mock hats + orgRegistry = OrgRegistry(address(new ERC1967Proxy(address(orgImpl), orgInit))); + + // Wire OrgRegistry to NameRegistryHub + orgRegistry.setNameRegistryHub(address(hub)); + hub.setAuthorizedOrgRegistry(address(orgRegistry), true); + } + + /*══════════════════ Helper ══════════════════*/ + + function _hubHandle(uint32 origin, address satellite, bytes memory body) internal { + vm.prank(address(homeMailbox)); + hub.handle(origin, bytes32(uint256(uint160(satellite))), body); + } + + /// @dev Deliver the last dispatched message from hub's StoringMailbox to a relay. + function _deliverConfirmToRelay(RegistryRelay relay, StoringMailbox relayMailbox) internal { + uint256 count = homeMailbox.dispatchedCount(); + require(count > 0, "no messages to deliver"); + StoringMailbox.DispatchedMessage memory msg_ = homeMailbox.getDispatched(count - 1); + vm.prank(address(relayMailbox)); + relay.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), msg_.messageBody); + } + + /*══════════════════ Home-Chain Registration ══════════════════*/ + + function testHomeChainRegister() public { + vm.prank(alice); + uar.registerAccount("alice"); + + assertEq(uar.getUsername(alice), "alice"); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + } + + function testHomeChainRegisterDuplicate() public { + vm.prank(alice); + uar.registerAccount("alice"); + + vm.prank(bob); + vm.expectRevert(); + uar.registerAccount("alice"); + } + + function testHomeChainRegisterCaseInsensitive() public { + vm.prank(alice); + uar.registerAccount("Alice"); + + vm.prank(bob); + vm.expectRevert(); + uar.registerAccount("alice"); + } + + function testHomeChainChangeUsername() public { + vm.prank(alice); + uar.registerAccount("alice"); + + vm.prank(alice); + uar.changeUsername("alice2"); + + assertEq(uar.getUsername(alice), "alice2"); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + assertTrue(hub.reserved(keccak256(bytes("alice2")))); + } + + function testHomeChainChangeUsernameTaken() public { + vm.prank(alice); + uar.registerAccount("alice"); + + vm.prank(bob); + uar.registerAccount("bob"); + + vm.prank(alice); + vm.expectRevert(); + uar.changeUsername("bob"); + } + + function testHomeChainDeleteAccount() public { + vm.prank(alice); + uar.registerAccount("alice"); + + vm.prank(alice); + uar.deleteAccount(); + + assertEq(uar.getUsername(alice), ""); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + } + + /*══════════════════ Cross-Chain Registration (Hub Side) ══════════════════*/ + + function testCrossChainClaimSuccess() public { + bytes memory claim = abi.encode(uint8(0x01), alice, "alice"); + _hubHandle(SAT_DOMAIN_A, address(relayA), claim); + + assertEq(uar.getUsername(alice), "alice"); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + + // Hub dispatched MSG_CONFIRM back + assertEq(homeMailbox.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(resp.destinationDomain, SAT_DOMAIN_A); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x02); // MSG_CONFIRM_USERNAME + } + + function testCrossChainRaceCondition() public { + bytes memory claimAlice = abi.encode(uint8(0x01), alice, "coolname"); + bytes memory claimBob = abi.encode(uint8(0x01), bob, "coolname"); + + // Alice arrives first → success + _hubHandle(SAT_DOMAIN_A, address(relayA), claimAlice); + assertEq(uar.getUsername(alice), "coolname"); + + // Bob arrives second → reject + _hubHandle(SAT_DOMAIN_B, address(relayB), claimBob); + assertEq(uar.getUsername(bob), ""); + + assertEq(homeMailbox.dispatchedCount(), 2); + + // Confirm to A + StoringMailbox.DispatchedMessage memory resp0 = homeMailbox.getDispatched(0); + assertEq(resp0.destinationDomain, SAT_DOMAIN_A); + assertEq(abi.decode(resp0.messageBody, (uint8)), 0x02); + + // Reject to B + StoringMailbox.DispatchedMessage memory resp1 = homeMailbox.getDispatched(1); + assertEq(resp1.destinationDomain, SAT_DOMAIN_B); + assertEq(abi.decode(resp1.messageBody, (uint8)), 0x03); + } + + function testCrossChainThenHomeChainFails() public { + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + + vm.prank(bob); + vm.expectRevert(); + uar.registerAccount("alice"); + } + + function testCrossChainBurn() public { + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x04), alice)); + + assertEq(uar.getUsername(alice), ""); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + } + + function testCrossChainChangeUsername() public { + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x05), alice, "alice2")); + + assertEq(uar.getUsername(alice), "alice2"); + assertTrue(hub.reserved(keccak256(bytes("alice")))); + assertTrue(hub.reserved(keccak256(bytes("alice2")))); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(1); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x02); + } + + function testCrossChainChangeUsernameTaken() public { + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x01), bob, "bob")); + + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x05), alice, "bob")); + + assertEq(uar.getUsername(alice), "alice"); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(2); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x03); + } + + /*══════════════════ Full Round-Trip ══════════════════*/ + + function testFullRoundTripConfirm() public { + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + _deliverConfirmToRelay(relayA, satMailboxA); + + assertEq(relayA.getUsername(alice), "alice"); + assertEq(relayA.getAddressOfUsername("alice"), alice); + } + + function testFullRoundTripReject() public { + // Alice takes "coolname" + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "coolname")); + + // Bob tries same → rejected + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x01), bob, "coolname")); + + // Deliver reject to relay B + StoringMailbox.DispatchedMessage memory rejectMsg = homeMailbox.getDispatched(1); + vm.prank(address(satMailboxB)); + relayB.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), rejectMsg.messageBody); + + assertEq(relayB.getUsername(bob), ""); + } + + /*══════════════════ Hub Security ══════════════════*/ + + function testHubRejectsUnauthorizedMailbox() public { + vm.prank(alice); + vm.expectRevert(NameRegistryHub.UnauthorizedMailbox.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(relayA)))), abi.encode(uint8(0x01), alice, "a")); + } + + function testHubRejectsUnregisteredSatellite() public { + vm.prank(address(homeMailbox)); + vm.expectRevert(NameRegistryHub.UnauthorizedSatellite.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(0xdead)))), abi.encode(uint8(0x01), alice, "a")); + } + + function testHubRejectsUnknownMessageType() public { + vm.prank(address(homeMailbox)); + vm.expectRevert(NameRegistryHub.UnknownMessageType.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(relayA)))), abi.encode(uint8(0xFF), alice, "a")); + } + + function testHubPause() public { + hub.setPaused(true); + vm.prank(address(homeMailbox)); + vm.expectRevert(NameRegistryHub.IsPaused.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(relayA)))), abi.encode(uint8(0x01), alice, "a")); + } + + function testHubCannotRenounceOwnership() public { + vm.expectRevert(NameRegistryHub.CannotRenounce.selector); + hub.renounceOwnership(); + } + + function testHubAdminBurn() public { + hub.adminBurn(keccak256(bytes("offensive"))); + assertTrue(hub.reserved(keccak256(bytes("offensive")))); + + vm.prank(alice); + vm.expectRevert(); + uar.registerAccount("offensive"); + } + + /*══════════════════ Satellite Management ══════════════════*/ + + function testRegisterDuplicateSatelliteFails() public { + vm.expectRevert(abi.encodeWithSelector(NameRegistryHub.DuplicateDomain.selector, SAT_DOMAIN_A)); + hub.registerSatellite(SAT_DOMAIN_A, address(0x1234)); + } + + function testRemoveSatellite() public { + hub.removeSatellite(0); + vm.prank(address(homeMailbox)); + vm.expectRevert(NameRegistryHub.UnauthorizedSatellite.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(relayA)))), abi.encode(uint8(0x01), alice, "a")); + } + + function testSatelliteCount() public view { + assertEq(hub.satelliteCount(), 2); + } + + /*══════════════════ Relay Tests ══════════════════*/ + + function testRelayRejectsUnauthorizedMailbox() public { + vm.prank(alice); + vm.expectRevert(RegistryRelay.UnauthorizedMailbox.selector); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "a")); + } + + function testRelayRejectsWrongOrigin() public { + vm.prank(address(satMailboxA)); + vm.expectRevert(RegistryRelay.UnauthorizedOrigin.selector); + relayA.handle(999, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "a")); + } + + function testRelayRejectsWrongSender() public { + vm.prank(address(satMailboxA)); + vm.expectRevert(RegistryRelay.UnauthorizedSender.selector); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(0xdead)))), abi.encode(uint8(0x02), alice, "a")); + } + + function testRelayConfirmUpdatesCache() public { + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "alice")); + + assertEq(relayA.getUsername(alice), "alice"); + assertEq(relayA.getAddressOfUsername("alice"), alice); + } + + function testRelayRejectEmitsEvent() public { + vm.expectEmit(true, false, false, true); + emit RegistryRelay.UsernameRejected(alice, "alice"); + + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x03), alice, "alice")); + + assertEq(relayA.getUsername(alice), ""); + } + + function testRelayPause() public { + relayA.setPaused(true); + vm.expectRevert(RegistryRelay.IsPaused.selector); + relayA.registerAccountDirect("alice"); + } + + function testRelayCannotRenounceOwnership() public { + vm.expectRevert(RegistryRelay.CannotRenounce.selector); + relayA.renounceOwnership(); + } + + function testRelayUsernameValidation() public { + vm.expectRevert(RegistryRelay.UsernameEmpty.selector); + relayA.registerAccountDirect(""); + + vm.expectRevert(RegistryRelay.InvalidChars.selector); + relayA.registerAccountDirect("bad name!"); + } + + function testRelayDeleteClearsCache() public { + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "alice")); + assertEq(relayA.getUsername(alice), "alice"); + + vm.prank(alice); + relayA.deleteAccount(); + assertEq(relayA.getUsername(alice), ""); + } + + /*══════════════════ UAR Standalone / Access ══════════════════*/ + + function testUARStandaloneMode() public { + UniversalAccountRegistry uarImpl = new UniversalAccountRegistry(); + UpgradeableBeacon uarBeacon = new UpgradeableBeacon(address(uarImpl), address(this)); + bytes memory initData = abi.encodeWithSignature("initialize(address)", address(this)); + UniversalAccountRegistry standalone = + UniversalAccountRegistry(address(new BeaconProxy(address(uarBeacon), initData))); + + assertEq(standalone.nameRegistryHub(), address(0)); + vm.prank(alice); + standalone.registerAccount("alice"); + assertEq(standalone.getUsername(alice), "alice"); + } + + function testUARCrossChainOnlyHub() public { + vm.prank(alice); + vm.expectRevert(UniversalAccountRegistry.NotHub.selector); + uar.registerAccountCrossChain(alice, "alice"); + } + + /*══════════════════ ETH ══════════════════*/ + + function testHubReceiveETH() public { + vm.deal(address(this), 1 ether); + (bool ok,) = address(hub).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(hub).balance, 1 ether); + } + + function testHubWithdrawETH() public { + vm.deal(address(hub), 1 ether); + hub.withdrawETH(payable(address(0x1234))); + assertEq(address(hub).balance, 0); + assertEq(address(0x1234).balance, 1 ether); + } + + /*══════════════════ Hub Return Fee ══════════════════*/ + + function testHubReturnFeeDispatch() public { + hub.setReturnFee(0.001 ether); + vm.deal(address(hub), 1 ether); + + // Cross-chain claim — hub should use returnFee for the confirm dispatch + bytes memory claim = abi.encode(uint8(0x01), alice, "alice"); + _hubHandle(SAT_DOMAIN_A, address(relayA), claim); + + assertEq(uar.getUsername(alice), "alice"); + assertEq(homeMailbox.dispatchedCount(), 1); + } + + function testHubReturnFeeInsufficientBalance() public { + hub.setReturnFee(1 ether); + // Hub has no ETH — should revert when trying to dispatch confirm + + bytes memory claim = abi.encode(uint8(0x01), alice, "alice"); + vm.prank(address(homeMailbox)); + vm.expectRevert(NameRegistryHub.InsufficientBalance.selector); + hub.handle(SAT_DOMAIN_A, bytes32(uint256(uint160(address(relayA)))), claim); + } + + /*══════════════════ Relay Signature Registration ══════════════════*/ + + function testRelayRegisterAccountBySig() public { + string memory username = "alice"; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = 0; + + // Build EIP-712 digest (relay's domain separator) + bytes32 registerTypehash = + keccak256("RegisterAccount(address user,string username,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(registerTypehash, alice, keccak256(bytes(username)), nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", relayA.DOMAIN_SEPARATOR(), structHash)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + relayA.registerAccount(alice, username, deadline, nonce, sig); + + // Verify claim was dispatched + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + assertEq(msg_.destinationDomain, HOME_DOMAIN); + (, address decodedUser, string memory decodedName) = abi.decode(msg_.messageBody, (uint8, address, string)); + assertEq(decodedUser, alice); + assertEq(decodedName, username); + + // Nonce should have incremented + assertEq(relayA.nonces(alice), 1); + } + + function testRelayRegisterAccountBySigBadSigner() public { + string memory username = "alice"; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = 0; + + bytes32 registerTypehash = + keccak256("RegisterAccount(address user,string username,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(registerTypehash, alice, keccak256(bytes(username)), nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", relayA.DOMAIN_SEPARATOR(), structHash)); + + // Sign with bob's key instead of alice's + (uint8 v, bytes32 r, bytes32 s) = vm.sign(bobKey, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.expectRevert(RegistryRelay.InvalidSigner.selector); + relayA.registerAccount(alice, username, deadline, nonce, sig); + } + + /*══════════════════ Relay Change Username ══════════════════*/ + + function testRelayChangeUsernameDispatch() public { + vm.prank(alice); + relayA.changeUsername("alice2"); + + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + (uint8 msgType, address user, string memory name) = abi.decode(msg_.messageBody, (uint8, address, string)); + assertEq(msgType, 0x05); // MSG_CHANGE_USERNAME + assertEq(user, alice); + assertEq(name, "alice2"); + } + + /*══════════════════ Relay Delete Dispatches Burn ══════════════════*/ + + function testRelayDeleteDispatchesBurn() public { + // Populate cache first + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "alice")); + + vm.prank(alice); + relayA.deleteAccount(); + + // Cache cleared + assertEq(relayA.getUsername(alice), ""); + assertEq(relayA.getAddressOfUsername("alice"), address(0)); + + // MSG_BURN dispatched + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + (uint8 msgType, address user) = abi.decode(msg_.messageBody, (uint8, address)); + assertEq(msgType, 0x04); // MSG_BURN_USERNAME + assertEq(user, alice); + } + + /*══════════════════ Stale Cache Fix ══════════════════*/ + + function testRelayCacheClearedOnUsernameChange() public { + // Confirm "alice" for alice + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "alice")); + assertEq(relayA.getUsername(alice), "alice"); + assertEq(relayA.getAddressOfUsername("alice"), alice); + + // Confirm "alice2" for alice (username change) + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0x02), alice, "alice2")); + + // New name resolves + assertEq(relayA.getUsername(alice), "alice2"); + assertEq(relayA.getAddressOfUsername("alice2"), alice); + + // Old name no longer resolves (was stale before the fix) + assertEq(relayA.getAddressOfUsername("alice"), address(0)); + } + + /*══════════════════ Hub Error Names ══════════════════*/ + + function testHubLocalFunctionsRejectNonRegistry() public { + vm.prank(alice); + vm.expectRevert(NameRegistryHub.NotAccountRegistry.selector); + hub.claimUsernameLocal(keccak256(bytes("test"))); + + vm.prank(alice); + vm.expectRevert(NameRegistryHub.NotAccountRegistry.selector); + hub.changeUsernameLocal(keccak256(bytes("old")), keccak256(bytes("new"))); + + vm.prank(alice); + vm.expectRevert(NameRegistryHub.NotAccountRegistry.selector); + hub.burnUsernameLocal(keccak256(bytes("test"))); + } + + /*══════════════════ Integration: Multi-Step Round-Trips ══════════════════*/ + + function testFullRoundTripChangeUsername() public { + // Register "alice" via satellite A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + _deliverConfirmToRelay(relayA, satMailboxA); + assertEq(relayA.getUsername(alice), "alice"); + assertEq(relayA.getAddressOfUsername("alice"), alice); + + // Change to "alice2" via satellite A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x05), alice, "alice2")); + _deliverConfirmToRelay(relayA, satMailboxA); + + // New name cached, old name cleared + assertEq(relayA.getUsername(alice), "alice2"); + assertEq(relayA.getAddressOfUsername("alice2"), alice); + assertEq(relayA.getAddressOfUsername("alice"), address(0)); + + // Both names reserved on hub (old name burned) + assertTrue(hub.reserved(keccak256(bytes("alice")))); + assertTrue(hub.reserved(keccak256(bytes("alice2")))); + } + + function testFullRoundTripDeleteAndReRegister() public { + // Register "alice" via satellite A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + _deliverConfirmToRelay(relayA, satMailboxA); + assertEq(relayA.getUsername(alice), "alice"); + + // Delete (burn) via satellite A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x04), alice)); + + // Try re-registering the burned name with a fresh address (charlie) + // to isolate that rejection is due to name being burned, not account existing + address charlie = vm.addr(0xc0de); + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), charlie, "alice")); + + // Charlie should get a reject (name is burned/reserved) + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(homeMailbox.dispatchedCount() - 1); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x03); // MSG_REJECT + } + + function testMultiSatelliteRegistration() public { + // Alice registers on relay A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + _deliverConfirmToRelay(relayA, satMailboxA); + + // Bob registers on relay B (different name) + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x01), bob, "bob")); + // Deliver confirm to relay B (second dispatched message) + StoringMailbox.DispatchedMessage memory msg_ = homeMailbox.getDispatched(1); + vm.prank(address(satMailboxB)); + relayB.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), msg_.messageBody); + + // Each relay has only its own user cached + assertEq(relayA.getUsername(alice), "alice"); + assertEq(relayA.getUsername(bob), ""); + assertEq(relayB.getUsername(bob), "bob"); + assertEq(relayB.getUsername(alice), ""); + + // Both globally reserved + assertTrue(hub.reserved(keccak256(bytes("alice")))); + assertTrue(hub.reserved(keccak256(bytes("bob")))); + } + + /*══════════════════ Integration: Cross-Chain vs Home-Chain ══════════════════*/ + + function testHomeChainBlocksCrossChainDuplicate() public { + // Register "alice" on home chain + vm.prank(alice); + uar.registerAccount("alice"); + + // Cross-chain claim for "alice" from satellite — should be rejected + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), bob, "alice")); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x03); // MSG_REJECT + } + + function testCrossChainBlocksHomeChainDuplicate() public { + // Cross-chain claim "alice" arrives first + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + assertEq(uar.getUsername(alice), "alice"); + + // Home-chain user tries to register same name — reverts + vm.prank(bob); + vm.expectRevert(); + uar.registerAccount("alice"); + } + + function testAdminBurnBlocksCrossChain() public { + // Admin burns a name + hub.adminBurn(keccak256(bytes("reserved-name"))); + + // Cross-chain claim for that name — should be rejected + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "reserved-name")); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x03); // MSG_REJECT + } + + /*══════════════════ Integration: Edge Cases ══════════════════*/ + + function testRelayRegisterExpiredDeadline() public { + string memory username = "alice"; + uint256 deadline = block.timestamp - 1; // expired + uint256 nonce = 0; + + bytes32 registerTypehash = + keccak256("RegisterAccount(address user,string username,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(registerTypehash, alice, keccak256(bytes(username)), nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", relayA.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.expectRevert(RegistryRelay.SignatureExpired.selector); + relayA.registerAccount(alice, username, deadline, nonce, sig); + } + + function testRelayRegisterBadNonce() public { + string memory username = "alice"; + uint256 deadline = block.timestamp + 1 hours; + uint256 nonce = 999; // wrong nonce (should be 0) + + bytes32 registerTypehash = + keccak256("RegisterAccount(address user,string username,uint256 nonce,uint256 deadline)"); + bytes32 structHash = keccak256(abi.encode(registerTypehash, alice, keccak256(bytes(username)), nonce, deadline)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", relayA.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(aliceKey, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + vm.expectRevert(RegistryRelay.InvalidNonce.selector); + relayA.registerAccount(alice, username, deadline, nonce, sig); + } + + function testRelayRejectsUnknownMessageType() public { + vm.prank(address(satMailboxA)); + vm.expectRevert(RegistryRelay.UnknownMessageType.selector); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), abi.encode(uint8(0xFF), alice, "a")); + } + + function testRelayUsernameTooLong() public { + // 65 characters — exceeds MAX_LEN of 64 + string memory longName = "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklm"; + vm.expectRevert(RegistryRelay.UsernameTooLong.selector); + relayA.registerAccountDirect(longName); + } + + function testRelayDirectRegisterDispatch() public { + vm.prank(alice); + relayA.registerAccountDirect("alice"); + + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + assertEq(msg_.destinationDomain, HOME_DOMAIN); + (uint8 msgType, address user, string memory name) = abi.decode(msg_.messageBody, (uint8, address, string)); + assertEq(msgType, 0x01); // MSG_CLAIM_USERNAME + assertEq(user, alice); + assertEq(name, "alice"); + } + + function testHubRegisterSatelliteZeroAddress() public { + vm.expectRevert(NameRegistryHub.ZeroAddress.selector); + hub.registerSatellite(999, address(0)); + } + + function testAdminBurnBlocksCrossChainUsernameChange() public { + // Register alice with name "alice" via cross-chain + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x01), alice, "alice")); + assertEq(uar.getUsername(alice), "alice"); + + // Admin burns "offensive" + hub.adminBurn(keccak256(bytes("offensive"))); + + // Alice tries to change to the burned name via cross-chain + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x05), alice, "offensive")); + + // Should get MSG_REJECT (not MSG_CONFIRM) + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(homeMailbox.dispatchedCount() - 1); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x03); // MSG_REJECT + + // Alice should still have her original name + assertEq(uar.getUsername(alice), "alice"); + } + + /*══════════════════ Home-Chain Org Names ══════════════════*/ + + function testHomeChainOrgNameReservation() public { + bytes32 orgId = keccak256("ORG1"); + orgRegistry.registerOrg(orgId, address(this), bytes("MyOrg"), bytes32(0)); + + // Name should be reserved on hub + bytes memory nameBytes = bytes("MyOrg"); + // Normalize to lowercase for hash + for (uint256 i; i < nameBytes.length; ++i) { + uint8 c = uint8(nameBytes[i]); + if (c >= 65 && c <= 90) nameBytes[i] = bytes1(c + 32); + } + assertTrue(hub.reservedOrgNames(keccak256(nameBytes))); + } + + function testHomeChainOrgNameDuplicate() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + orgRegistry.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + + vm.expectRevert(); + orgRegistry.registerOrg(orgB, address(this), bytes("Alpha"), bytes32(0)); + } + + function testHomeChainOrgNameCaseInsensitive() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + orgRegistry.registerOrg(orgA, address(this), bytes("MyOrg"), bytes32(0)); + + vm.expectRevert(); + orgRegistry.registerOrg(orgB, address(this), bytes("myorg"), bytes32(0)); + } + + function testHomeChainOrgNameChange() public { + bytes32 orgId = keccak256("ORG1"); + orgRegistry.registerOrg(orgId, address(this), bytes("Alpha"), bytes32(0)); + + // Rename Alpha → Gamma + orgRegistry.updateOrgMeta(orgId, bytes("Gamma"), bytes32(0)); + + // Old name should be released (org names release on rename, unlike usernames) + bytes memory oldNameBytes = bytes("alpha"); + assertFalse(hub.reservedOrgNames(keccak256(oldNameBytes))); + + // New name should be reserved + bytes memory newNameBytes = bytes("gamma"); + assertTrue(hub.reservedOrgNames(keccak256(newNameBytes))); + } + + function testHomeChainOrgNameChangeToTaken() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + orgRegistry.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + orgRegistry.registerOrg(orgB, address(this), bytes("Beta"), bytes32(0)); + + vm.expectRevert(); + orgRegistry.updateOrgMeta(orgA, bytes("Beta"), bytes32(0)); + } + + /*══════════════════ Cross-Chain Org Names (Hub Side) ══════════════════*/ + + function testCrossChainOrgNameClaimSuccess() public { + bytes memory claim = abi.encode(uint8(0x06), "CrossOrg"); + _hubHandle(SAT_DOMAIN_A, address(relayA), claim); + + // Name reserved on hub + bytes memory nameBytes = bytes("crossorg"); + assertTrue(hub.reservedOrgNames(keccak256(nameBytes))); + + // Confirm dispatched + assertEq(homeMailbox.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(resp.destinationDomain, SAT_DOMAIN_A); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x07); // MSG_CONFIRM_ORG_NAME + } + + function testCrossChainOrgNameClaimRejected() public { + // First claim succeeds + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "CrossOrg")); + + // Second claim for same name rejected + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x06), "CrossOrg")); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(1); + assertEq(resp.destinationDomain, SAT_DOMAIN_B); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x08); // MSG_REJECT_ORG_NAME + } + + function testCrossChainOrgNameRaceCondition() public { + // Two satellites claim same name — first-come-first-served + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "RaceName")); + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x06), "RaceName")); + + // Confirm to A + StoringMailbox.DispatchedMessage memory resp0 = homeMailbox.getDispatched(0); + assertEq(resp0.destinationDomain, SAT_DOMAIN_A); + assertEq(abi.decode(resp0.messageBody, (uint8)), 0x07); + + // Reject to B + StoringMailbox.DispatchedMessage memory resp1 = homeMailbox.getDispatched(1); + assertEq(resp1.destinationDomain, SAT_DOMAIN_B); + assertEq(abi.decode(resp1.messageBody, (uint8)), 0x08); + } + + /*══════════════════ Cross-Chain vs Home-Chain Org Names ══════════════════*/ + + function testHomeChainBlocksCrossChainOrgName() public { + // Register org on home chain — reserves name + orgRegistry.registerOrg(keccak256("ORG1"), address(this), bytes("Alpha"), bytes32(0)); + + // Cross-chain claim for same name — should be rejected + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "Alpha")); + + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x08); // MSG_REJECT_ORG_NAME + } + + function testCrossChainBlocksHomeChainOrgName() public { + // Cross-chain reserves name first + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "Alpha")); + + // Home-chain org creation with same name — should revert + vm.expectRevert(); + orgRegistry.registerOrg(keccak256("ORG1"), address(this), bytes("Alpha"), bytes32(0)); + } + + /*══════════════════ Org Name Admin + Namespace ══════════════════*/ + + function testAdminBurnOrgName() public { + // Admin burns an org name + hub.adminBurnOrgName(keccak256(bytes("burned"))); + assertTrue(hub.reservedOrgNames(keccak256(bytes("burned")))); + + // Cross-chain claim for burned name — rejected + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "burned")); + StoringMailbox.DispatchedMessage memory resp = homeMailbox.getDispatched(0); + assertEq(abi.decode(resp.messageBody, (uint8)), 0x08); // MSG_REJECT_ORG_NAME + + // Home-chain claim for burned name — reverts + vm.expectRevert(); + orgRegistry.registerOrg(keccak256("ORG1"), address(this), bytes("burned"), bytes32(0)); + } + + function testOrgNameAndUsernameIndependent() public { + // Same string can be both a username and an org name (separate namespaces) + vm.prank(alice); + uar.registerAccount("alpha"); + + orgRegistry.registerOrg(keccak256("ORG1"), address(this), bytes("alpha"), bytes32(0)); + + // Both succeed — independent namespaces + assertEq(uar.getUsername(alice), "alpha"); + bytes memory nameBytes = bytes("alpha"); + assertTrue(hub.reservedOrgNames(keccak256(nameBytes))); + assertTrue(hub.reserved(keccak256(nameBytes))); + } + + /*══════════════════ Org Name Full Round-Trip ══════════════════*/ + + function testFullRoundTripOrgNameConfirm() public { + // Claim org name via satellite A + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "MyOrg")); + + // Deliver confirm to relay + _deliverConfirmToRelay(relayA, satMailboxA); + + // Relay cache updated + assertTrue(relayA.isOrgNameConfirmed("MyOrg")); + } + + function testFullRoundTripOrgNameReject() public { + // First claim succeeds + _hubHandle(SAT_DOMAIN_A, address(relayA), abi.encode(uint8(0x06), "MyOrg")); + + // Second claim from B — rejected + _hubHandle(SAT_DOMAIN_B, address(relayB), abi.encode(uint8(0x06), "MyOrg")); + + // Deliver reject to relay B + StoringMailbox.DispatchedMessage memory rejectMsg = homeMailbox.getDispatched(1); + vm.prank(address(satMailboxB)); + relayB.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), rejectMsg.messageBody); + + // Relay B cache NOT updated + assertFalse(relayB.isOrgNameConfirmed("MyOrg")); + } + + /*══════════════════ Org Name Relay Edge Cases ══════════════════*/ + + function testRelayOrgNameClaimEmpty() public { + vm.expectRevert(RegistryRelay.OrgNameEmpty.selector); + relayA.claimOrgName(""); + } + + function testRelayOrgNameClaimDispatch() public { + relayA.claimOrgName("TestOrg"); + + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + (uint8 msgType, string memory name) = abi.decode(msg_.messageBody, (uint8, string)); + assertEq(msgType, 0x06); // MSG_CLAIM_ORG_NAME + assertEq(name, "TestOrg"); + } + + function testRelayOrgNameClaimRequiresOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", alice)); + relayA.claimOrgName("Squatted"); + } + + function testHubOrgRegistryAuthorization() public { + // Unauthorized address cannot call claimOrgNameLocal + vm.prank(alice); + vm.expectRevert(NameRegistryHub.NotOrgRegistry.selector); + hub.claimOrgNameLocal(keccak256(bytes("test")), "test"); + + // Unauthorized address cannot call changeOrgNameLocal + vm.prank(alice); + vm.expectRevert(NameRegistryHub.NotOrgRegistry.selector); + hub.changeOrgNameLocal(keccak256(bytes("old")), keccak256(bytes("new"))); + } + + /*══════════════════ Upgradeability: Double-Init & Zero-Owner ══════════════════*/ + + function testHubDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + hub.initialize(address(this), address(uar), address(homeMailbox)); + } + + function testRelayDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + relayA.initialize(address(this), address(satMailboxA), HOME_DOMAIN, address(hub)); + } + + function testHubInitializeRevertsZeroOwner() public { + NameRegistryHub hubImpl2 = new NameRegistryHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = + abi.encodeCall(NameRegistryHub.initialize, (address(0), address(uar), address(homeMailbox))); + vm.expectRevert(NameRegistryHub.ZeroAddress.selector); + new BeaconProxy(address(hubBeacon2), badInit); + } + + function testHubInitializeRevertsZeroAccountRegistry() public { + NameRegistryHub hubImpl2 = new NameRegistryHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = + abi.encodeCall(NameRegistryHub.initialize, (address(this), address(0), address(homeMailbox))); + vm.expectRevert(NameRegistryHub.ZeroAddress.selector); + new BeaconProxy(address(hubBeacon2), badInit); + } + + function testHubInitializeRevertsZeroMailbox() public { + NameRegistryHub hubImpl2 = new NameRegistryHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = abi.encodeCall(NameRegistryHub.initialize, (address(this), address(uar), address(0))); + vm.expectRevert(NameRegistryHub.ZeroAddress.selector); + new BeaconProxy(address(hubBeacon2), badInit); + } + + function testRelayInitializeRevertsZeroOwner() public { + RegistryRelay relayImpl2 = new RegistryRelay(); + UpgradeableBeacon relayBeacon2 = new UpgradeableBeacon(address(relayImpl2), address(this)); + bytes memory badInit = + abi.encodeCall(RegistryRelay.initialize, (address(0), address(satMailboxA), HOME_DOMAIN, address(hub))); + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + new BeaconProxy(address(relayBeacon2), badInit); + } + + function testRelayInitializeRevertsZeroMailbox() public { + RegistryRelay relayImpl2 = new RegistryRelay(); + UpgradeableBeacon relayBeacon2 = new UpgradeableBeacon(address(relayImpl2), address(this)); + bytes memory badInit = + abi.encodeCall(RegistryRelay.initialize, (address(this), address(0), HOME_DOMAIN, address(hub))); + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + new BeaconProxy(address(relayBeacon2), badInit); + } + + function testRelayInitializeRevertsZeroHubAddress() public { + RegistryRelay relayImpl2 = new RegistryRelay(); + UpgradeableBeacon relayBeacon2 = new UpgradeableBeacon(address(relayImpl2), address(this)); + bytes memory badInit = + abi.encodeCall(RegistryRelay.initialize, (address(this), address(satMailboxA), HOME_DOMAIN, address(0))); + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + new BeaconProxy(address(relayBeacon2), badInit); + } + + /*══════════════════ RegistryRelay: registerAccountForUser ══════════════════*/ + + function testRegisterAccountForUser() public { + address helperContract = address(0xBEEF); + relayA.setAuthorizedCaller(helperContract, true); + assertTrue(relayA.authorizedCallers(helperContract)); + + vm.prank(helperContract); + relayA.registerAccountForUser(alice, "alice"); + + // Verify dispatch + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + (uint8 msgType, address user, string memory name) = abi.decode(msg_.messageBody, (uint8, address, string)); + assertEq(msgType, 0x01); // MSG_CLAIM_USERNAME + assertEq(user, alice); + assertEq(name, "alice"); + } + + function testRegisterAccountForUserRevertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(RegistryRelay.UnauthorizedCaller.selector); + relayA.registerAccountForUser(alice, "alice"); + } + + function testRegisterAccountForUserRevertsZeroUser() public { + address helperContract = address(0xBEEF); + relayA.setAuthorizedCaller(helperContract, true); + + vm.prank(helperContract); + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + relayA.registerAccountForUser(address(0), "alice"); + } + + function testRegisterAccountForUserRevertsPaused() public { + address helperContract = address(0xBEEF); + relayA.setAuthorizedCaller(helperContract, true); + relayA.setPaused(true); + + vm.prank(helperContract); + vm.expectRevert(RegistryRelay.IsPaused.selector); + relayA.registerAccountForUser(alice, "alice"); + } + + function testSetAuthorizedCaller() public { + address helperContract = address(0xBEEF); + + relayA.setAuthorizedCaller(helperContract, true); + assertTrue(relayA.authorizedCallers(helperContract)); + + relayA.setAuthorizedCaller(helperContract, false); + assertFalse(relayA.authorizedCallers(helperContract)); + } + + function testSetAuthorizedCallerRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", alice)); + relayA.setAuthorizedCaller(address(0xBEEF), true); + } + + function testSetAuthorizedCallerRevertsZeroAddress() public { + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + relayA.setAuthorizedCaller(address(0), true); + } + + /*══════════════════ RegistryRelay: Org Name Release ══════════════════*/ + + function testDispatchOrgNameRelease() public { + // Confirm a name first + bytes32 nameHash = _hashName("ReleaseMe"); + bytes memory confirmBody = abi.encode(uint8(0x07), "ReleaseMe"); // MSG_CONFIRM_ORG_NAME + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), confirmBody); + assertTrue(relayA.isOrgNameConfirmed("ReleaseMe")); + + // Authorize a caller and dispatch release + address adapter = address(0xADA9); + relayA.setAuthorizedCaller(adapter, true); + + vm.prank(adapter); + relayA.dispatchOrgNameRelease(nameHash); + + // Local cache should be cleared + assertFalse(relayA.isOrgNameConfirmed("ReleaseMe")); + + // Verify Hyperlane dispatch + assertEq(satMailboxA.dispatchedCount(), 1); + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(0); + (uint8 msgType, bytes32 releasedHash) = abi.decode(msg_.messageBody, (uint8, bytes32)); + assertEq(msgType, 0x09); // MSG_RELEASE_ORG_NAME + assertEq(releasedHash, nameHash); + } + + function testDispatchOrgNameReleaseRevertsUnauthorized() public { + vm.prank(alice); + vm.expectRevert(RegistryRelay.UnauthorizedCaller.selector); + relayA.dispatchOrgNameRelease(keccak256("test")); + } + + function testSetDispatchFee() public { + relayA.setDispatchFee(0.001 ether); + assertEq(relayA.dispatchFee(), 0.001 ether); + } + + function testRelayReceiveETH() public { + (bool ok,) = address(relayA).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(relayA).balance, 1 ether); + } + + function testRelayWithdrawETH() public { + // Fund relay + (bool ok,) = address(relayA).call{value: 1 ether}(""); + assertTrue(ok); + + address payable recipient = payable(address(0xCAFE)); + relayA.withdrawETH(recipient); + assertEq(address(relayA).balance, 0); + assertEq(recipient.balance, 1 ether); + } + + function testRelayWithdrawETHRevertsZeroAddress() public { + vm.expectRevert(RegistryRelay.ZeroAddress.selector); + relayA.withdrawETH(payable(address(0))); + } + + function testRelayWithdrawETHRevertsNonOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", alice)); + relayA.withdrawETH(payable(alice)); + } + + function testDispatchOrgNameReleaseWithFee() public { + // Set fee and fund relay + relayA.setDispatchFee(0.001 ether); + (bool ok,) = address(relayA).call{value: 0.01 ether}(""); + assertTrue(ok); + + // Confirm a name + bytes memory confirmBody = abi.encode(uint8(0x07), "FeeTest"); // MSG_CONFIRM_ORG_NAME + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), confirmBody); + + // Dispatch release + address adapter = address(0xADA9); + relayA.setAuthorizedCaller(adapter, true); + vm.prank(adapter); + relayA.dispatchOrgNameRelease(_hashName("FeeTest")); + + assertFalse(relayA.isOrgNameConfirmed("FeeTest")); + } + + function testDispatchOrgNameReleaseInsufficientBalance() public { + relayA.setDispatchFee(1 ether); // fee set but relay has no ETH + + address adapter = address(0xADA9); + relayA.setAuthorizedCaller(adapter, true); + + vm.prank(adapter); + vm.expectRevert(RegistryRelay.InsufficientBalance.selector); + relayA.dispatchOrgNameRelease(keccak256("test")); + } + + /*══════════════════ NameRegistryHub: Org Name Release ══════════════════*/ + + function testHandleReleaseOrgName() public { + // First reserve the name on the hub + bytes32 nameHash = _hashName("ToRelease"); + bytes memory claimBody = abi.encode(uint8(0x06), "ToRelease"); // MSG_CLAIM_ORG_NAME + _hubHandle(SAT_DOMAIN_A, address(relayA), claimBody); + assertTrue(hub.reservedOrgNames(nameHash)); + + // Now release it + bytes memory releaseBody = abi.encode(uint8(0x09), nameHash); // MSG_RELEASE_ORG_NAME + _hubHandle(SAT_DOMAIN_A, address(relayA), releaseBody); + assertFalse(hub.reservedOrgNames(nameHash)); + } + + function testHandleReleaseOrgNameIdempotent() public { + // Release a name that was never reserved — should not revert + bytes32 nameHash = keccak256("NeverReserved"); + bytes memory releaseBody = abi.encode(uint8(0x09), nameHash); // MSG_RELEASE_ORG_NAME + _hubHandle(SAT_DOMAIN_A, address(relayA), releaseBody); + assertFalse(hub.reservedOrgNames(nameHash)); + } + + /*══════════════════ Integration: Rename + Auto-Release ══════════════════*/ + + function testOrgNameRenameAutoRelease() public { + // 1. Claim "OriginalOrg" on hub from satellite A + bytes32 origHash = _hashName("OriginalOrg"); + bytes memory claimBody = abi.encode(uint8(0x06), "OriginalOrg"); + _hubHandle(SAT_DOMAIN_A, address(relayA), claimBody); + assertTrue(hub.reservedOrgNames(origHash)); + + // 2. Simulate confirmation arriving at relay + bytes memory confirmBody = abi.encode(uint8(0x07), "OriginalOrg"); + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), confirmBody); + assertTrue(relayA.isOrgNameConfirmed("OriginalOrg")); + + // 3. Claim "NewOrgName" on hub too (new name must be pre-confirmed) + bytes32 newHash = _hashName("NewOrgName"); + bytes memory claimBody2 = abi.encode(uint8(0x06), "NewOrgName"); + _hubHandle(SAT_DOMAIN_A, address(relayA), claimBody2); + bytes memory confirmBody2 = abi.encode(uint8(0x07), "NewOrgName"); + vm.prank(address(satMailboxA)); + relayA.handle(HOME_DOMAIN, bytes32(uint256(uint160(address(hub)))), confirmBody2); + + // 4. Simulate the adapter's auto-release during rename + address adapter = address(0xADA9); + relayA.setAuthorizedCaller(adapter, true); + vm.prank(adapter); + relayA.dispatchOrgNameRelease(origHash); + + // Local cache cleared + assertFalse(relayA.isOrgNameConfirmed("OriginalOrg")); + + // 5. Deliver release to hub + StoringMailbox.DispatchedMessage memory msg_ = satMailboxA.getDispatched(satMailboxA.dispatchedCount() - 1); + _hubHandle(SAT_DOMAIN_A, address(relayA), msg_.messageBody); + + // Hub freed the name + assertFalse(hub.reservedOrgNames(origHash)); + // New name still reserved + assertTrue(hub.reservedOrgNames(newHash)); + + // 6. Another satellite can now claim the original name + bytes memory reclaimBody = abi.encode(uint8(0x06), "OriginalOrg"); + _hubHandle(SAT_DOMAIN_B, address(relayB), reclaimBody); + assertTrue(hub.reservedOrgNames(origHash)); // re-reserved by satellite B + } + + /*══════════════════ Helper ══════════════════*/ + + function _hashName(string memory name) internal pure returns (bytes32) { + bytes memory b = bytes(name); + for (uint256 i; i < b.length; ++i) { + uint8 c = uint8(b[i]); + if (c >= 65 && c <= 90) b[i] = bytes1(c + 32); + } + return keccak256(b); + } +} diff --git a/test/CrossChainUpgradeIntegration.t.sol b/test/CrossChainUpgradeIntegration.t.sol index 8c63d43..87e1855 100644 --- a/test/CrossChainUpgradeIntegration.t.sol +++ b/test/CrossChainUpgradeIntegration.t.sol @@ -43,17 +43,17 @@ contract CrossChainUpgradeIntegrationTest is Test { // ── Home Chain ── homePm = _deployPoaManager(); - hub = new PoaManagerHub(address(homePm), address(mailbox)); + hub = _deployHub(homePm, address(mailbox)); homePm.transferOwnership(address(hub)); // ── Satellite 1 ── sat1Pm = _deployPoaManager(); - satellite1 = new PoaManagerSatellite(address(sat1Pm), address(mailbox), 1, address(hub)); + satellite1 = _deploySatellite(sat1Pm, address(mailbox), 1, address(hub)); sat1Pm.transferOwnership(address(satellite1)); // ── Satellite 2 ── sat2Pm = _deployPoaManager(); - satellite2 = new PoaManagerSatellite(address(sat2Pm), address(mailbox), 1, address(hub)); + satellite2 = _deploySatellite(sat2Pm, address(mailbox), 1, address(hub)); sat2Pm.transferOwnership(address(satellite2)); // Register satellites in Hub @@ -79,6 +79,25 @@ contract CrossChainUpgradeIntegrationTest is Test { return pm; } + function _deployHub(PoaManager pm, address _mailbox) internal returns (PoaManagerHub) { + PoaManagerHub impl = new PoaManagerHub(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + bytes memory init = abi.encodeCall(PoaManagerHub.initialize, (address(this), address(pm), _mailbox)); + return PoaManagerHub(payable(address(new BeaconProxy(address(beacon), init)))); + } + + function _deploySatellite(PoaManager pm, address _mailbox, uint32 _hubDomain, address _hubAddress) + internal + returns (PoaManagerSatellite) + { + PoaManagerSatellite impl = new PoaManagerSatellite(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + bytes memory init = abi.encodeCall( + PoaManagerSatellite.initialize, (address(this), address(pm), _mailbox, _hubDomain, _hubAddress) + ); + return PoaManagerSatellite(payable(address(new BeaconProxy(address(beacon), init)))); + } + // ══════════════════════════════════════════════════════════ // 1. Cross-chain upgrade propagates to all satellites // ══════════════════════════════════════════════════════════ @@ -257,7 +276,7 @@ contract CrossChainUpgradeIntegrationTest is Test { function testDynamicSatelliteRegistration() public { // Deploy a third satellite PoaManager sat3Pm = _deployPoaManager(); - PoaManagerSatellite satellite3 = new PoaManagerSatellite(address(sat3Pm), address(mailbox), 1, address(hub)); + PoaManagerSatellite satellite3 = _deploySatellite(sat3Pm, address(mailbox), 1, address(hub)); sat3Pm.transferOwnership(address(satellite3)); satellite3.addContractType("TestType", address(implV1)); diff --git a/test/GovernanceCrossChainUpgrade.t.sol b/test/GovernanceCrossChainUpgrade.t.sol index ad16912..6958008 100644 --- a/test/GovernanceCrossChainUpgrade.t.sol +++ b/test/GovernanceCrossChainUpgrade.t.sol @@ -100,12 +100,26 @@ contract GovernanceCrossChainUpgradeTest is Test { satPM = new PoaManager(address(satReg)); satReg.transferOwnership(address(satPM)); - // ── Hub (initially owned by this test contract) ── - hub = new PoaManagerHub(address(homePM), address(mailbox)); + // ── Hub (initially owned by this test contract, deployed behind BeaconProxy) ── + { + PoaManagerHub hubImpl = new PoaManagerHub(); + UpgradeableBeacon hubBeacon = new UpgradeableBeacon(address(hubImpl), address(this)); + bytes memory hubInit = + abi.encodeCall(PoaManagerHub.initialize, (address(this), address(homePM), address(mailbox))); + hub = PoaManagerHub(payable(address(new BeaconProxy(address(hubBeacon), hubInit)))); + } homePM.transferOwnership(address(hub)); - // ── Satellite ── - satellite = new PoaManagerSatellite(address(satPM), address(mailbox), HOME_DOMAIN, address(hub)); + // ── Satellite (deployed behind BeaconProxy) ── + { + PoaManagerSatellite satImpl = new PoaManagerSatellite(); + UpgradeableBeacon satBeacon = new UpgradeableBeacon(address(satImpl), address(this)); + bytes memory satInit = abi.encodeCall( + PoaManagerSatellite.initialize, + (address(this), address(satPM), address(mailbox), HOME_DOMAIN, address(hub)) + ); + satellite = PoaManagerSatellite(payable(address(new BeaconProxy(address(satBeacon), satInit)))); + } satPM.transferOwnership(address(satellite)); // Register satellite on hub and add contract type on both chains diff --git a/test/OrgRegistry.t.sol b/test/OrgRegistry.t.sol index 20c32d8..017b0be 100644 --- a/test/OrgRegistry.t.sol +++ b/test/OrgRegistry.t.sol @@ -175,4 +175,79 @@ contract OrgRegistryTest is Test { vm.expectRevert(NotOrgMetadataAdmin.selector); reg.updateOrgMetaAsAdmin(ORG_ID, bytes("New Name"), bytes32(uint256(1))); } + + /* ══════════ Org Name Uniqueness on Update ══════════ */ + + function testUpdateOrgMeta_RevertWhenNameTaken() public { + // Register two orgs with different names + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + reg.registerOrg(orgB, address(this), bytes("Beta"), bytes32(0)); + + // Try to rename Alpha to Beta via executor path — should fail + vm.expectRevert(OrgNameTaken.selector); + reg.updateOrgMeta(orgA, bytes("Beta"), bytes32(0)); + } + + function testUpdateOrgMeta_SameNameNoOp() public { + bytes32 orgA = keccak256("ORG_A"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + + // Updating to the same name should succeed (no-op) + reg.updateOrgMeta(orgA, bytes("Alpha"), bytes32(uint256(1))); + } + + function testUpdateOrgMeta_ReleasesOldName() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + + // Rename Alpha → Gamma + reg.updateOrgMeta(orgA, bytes("Gamma"), bytes32(0)); + + // "Alpha" is now free — a new org should be able to claim it + reg.registerOrg(orgB, address(this), bytes("Alpha"), bytes32(0)); + assertTrue(reg.isOrgNameTaken(bytes("Alpha"))); + } + + function testUpdateOrgMeta_CaseInsensitive() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + reg.registerOrg(orgB, address(this), bytes("Beta"), bytes32(0)); + + // "BETA" should collide with "Beta" (case-insensitive) + vm.expectRevert(OrgNameTaken.selector); + reg.updateOrgMeta(orgA, bytes("BETA"), bytes32(0)); + } + + function testUpdateOrgMetaAsAdmin_RevertWhenNameTaken() public { + bytes32 orgA = keccak256("ORG_A"); + bytes32 orgB = keccak256("ORG_B"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + reg.registerOrg(orgB, address(this), bytes("Beta"), bytes32(0)); + + // Setup hats for admin path + uint256[] memory roleHats = new uint256[](0); + reg.registerHatsTree(orgA, TOP_HAT_ID, roleHats); + mockHats.setWearer(ADMIN_USER, TOP_HAT_ID, true); + + // Admin tries to rename Alpha to Beta — should fail + vm.prank(ADMIN_USER); + vm.expectRevert(OrgNameTaken.selector); + reg.updateOrgMetaAsAdmin(orgA, bytes("Beta"), bytes32(0)); + } + + function testUpdateOrgMeta_EmptyNameSkipsUniqueness() public { + bytes32 orgA = keccak256("ORG_A"); + reg.registerOrg(orgA, address(this), bytes("Alpha"), bytes32(0)); + + // Empty name = metadata-only update, should not touch name mappings + reg.updateOrgMeta(orgA, bytes(""), bytes32(uint256(42))); + + // Name still registered + assertTrue(reg.isOrgNameTaken(bytes("Alpha"))); + assertEq(reg.orgIdOfName(bytes("Alpha")), orgA); + } } diff --git a/test/PoaManagerHub.t.sol b/test/PoaManagerHub.t.sol index ec70872..5edf19a 100644 --- a/test/PoaManagerHub.t.sol +++ b/test/PoaManagerHub.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {PoaManager} from "../src/PoaManager.sol"; import {ImplementationRegistry} from "../src/ImplementationRegistry.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; @@ -55,8 +56,11 @@ contract PoaManagerHubTest is Test { // Deploy MockMailbox on domain 1 mailbox = new MockMailbox(1); - // Deploy Hub - hub = new PoaManagerHub(address(pm), address(mailbox)); + // Deploy Hub behind a beacon proxy + PoaManagerHub hubImpl = new PoaManagerHub(); + UpgradeableBeacon hubBeacon = new UpgradeableBeacon(address(hubImpl), address(this)); + bytes memory hubInit = abi.encodeCall(PoaManagerHub.initialize, (address(this), address(pm), address(mailbox))); + hub = PoaManagerHub(payable(address(new BeaconProxy(address(hubBeacon), hubInit)))); // Transfer PM ownership to hub pm.transferOwnership(address(hub)); @@ -228,21 +232,27 @@ contract PoaManagerHubTest is Test { } // ══════════════════════════════════════════════════════════ - // 10. Constructor reverts on zero poaManager address + // 10. Initialize reverts on zero poaManager address // ══════════════════════════════════════════════════════════ - function testConstructorRevertsZeroPoaManager() public { + function testInitializeRevertsZeroPoaManager() public { + PoaManagerHub hubImpl2 = new PoaManagerHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = abi.encodeCall(PoaManagerHub.initialize, (address(this), address(0), address(mailbox))); vm.expectRevert(PoaManagerHub.ZeroAddress.selector); - new PoaManagerHub(address(0), address(mailbox)); + new BeaconProxy(address(hubBeacon2), badInit); } // ══════════════════════════════════════════════════════════ - // 11. Constructor reverts on zero mailbox address + // 11. Initialize reverts on zero mailbox address // ══════════════════════════════════════════════════════════ - function testConstructorRevertsZeroMailbox() public { + function testInitializeRevertsZeroMailbox() public { + PoaManagerHub hubImpl2 = new PoaManagerHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = abi.encodeCall(PoaManagerHub.initialize, (address(this), address(pm), address(0))); vm.expectRevert(PoaManagerHub.ZeroAddress.selector); - new PoaManagerHub(address(pm), address(0)); + new BeaconProxy(address(hubBeacon2), badInit); } // ══════════════════════════════════════════════════════════ @@ -732,6 +742,27 @@ contract PoaManagerHubTest is Test { hub.adminCall(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 99)); } + // ══════════════════════════════════════════════════════════ + // Initialize reverts on zero owner + // ══════════════════════════════════════════════════════════ + + function testInitializeRevertsZeroOwner() public { + PoaManagerHub hubImpl2 = new PoaManagerHub(); + UpgradeableBeacon hubBeacon2 = new UpgradeableBeacon(address(hubImpl2), address(this)); + bytes memory badInit = abi.encodeCall(PoaManagerHub.initialize, (address(0), address(pm), address(mailbox))); + vm.expectRevert(PoaManagerHub.ZeroAddress.selector); + new BeaconProxy(address(hubBeacon2), badInit); + } + + // ══════════════════════════════════════════════════════════ + // Double initialization reverts + // ══════════════════════════════════════════════════════════ + + function testDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + hub.initialize(address(this), address(pm), address(mailbox)); + } + // ══════════════════════════════════════════════════════════ // Helper: accept ETH refunds // ══════════════════════════════════════════════════════════ diff --git a/test/PoaManagerSatellite.t.sol b/test/PoaManagerSatellite.t.sol index b5114ba..dcd8085 100644 --- a/test/PoaManagerSatellite.t.sol +++ b/test/PoaManagerSatellite.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; import {PoaManager} from "../src/PoaManager.sol"; import {ImplementationRegistry} from "../src/ImplementationRegistry.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; @@ -27,6 +28,8 @@ contract PoaManagerSatelliteTest is Test { PoaManager pm; PoaManagerSatellite satellite; + UpgradeableBeacon satBeacon; + SatDummyImplV1 implV1; SatDummyImplV2 implV2; @@ -46,8 +49,12 @@ contract PoaManagerSatelliteTest is Test { pm = new PoaManager(address(reg)); reg.transferOwnership(address(pm)); - // Deploy Satellite - satellite = new PoaManagerSatellite(address(pm), mailbox, hubDomain, hubAddr); + // Deploy Satellite behind beacon proxy + PoaManagerSatellite satImpl = new PoaManagerSatellite(); + satBeacon = new UpgradeableBeacon(address(satImpl), address(this)); + bytes memory satInit = + abi.encodeCall(PoaManagerSatellite.initialize, (address(this), address(pm), mailbox, hubDomain, hubAddr)); + satellite = PoaManagerSatellite(address(new BeaconProxy(address(satBeacon), satInit))); // Transfer PM ownership to satellite pm.transferOwnership(address(satellite)); @@ -292,30 +299,38 @@ contract PoaManagerSatelliteTest is Test { } // ══════════════════════════════════════════════════════════ - // 18. Constructor reverts on zero poaManager + // 18. Initialize reverts on zero poaManager // ══════════════════════════════════════════════════════════ - function testConstructorRevertsZeroPoaManager() public { + function testInitializeRevertsZeroPoaManager() public { + bytes memory badInit = + abi.encodeCall(PoaManagerSatellite.initialize, (address(this), address(0), mailbox, hubDomain, hubAddr)); vm.expectRevert(PoaManagerSatellite.ZeroAddress.selector); - new PoaManagerSatellite(address(0), mailbox, hubDomain, hubAddr); + new BeaconProxy(address(satBeacon), badInit); } // ══════════════════════════════════════════════════════════ - // 19. Constructor reverts on zero mailbox + // 19. Initialize reverts on zero mailbox // ══════════════════════════════════════════════════════════ - function testConstructorRevertsZeroMailbox() public { + function testInitializeRevertsZeroMailbox() public { + bytes memory badInit = abi.encodeCall( + PoaManagerSatellite.initialize, (address(this), address(pm), address(0), hubDomain, hubAddr) + ); vm.expectRevert(PoaManagerSatellite.ZeroAddress.selector); - new PoaManagerSatellite(address(pm), address(0), hubDomain, hubAddr); + new BeaconProxy(address(satBeacon), badInit); } // ══════════════════════════════════════════════════════════ - // 20. Constructor reverts on zero hubAddress + // 20. Initialize reverts on zero hubAddress // ══════════════════════════════════════════════════════════ - function testConstructorRevertsZeroHubAddress() public { + function testInitializeRevertsZeroHubAddress() public { + bytes memory badInit = abi.encodeCall( + PoaManagerSatellite.initialize, (address(this), address(pm), mailbox, hubDomain, address(0)) + ); vm.expectRevert(PoaManagerSatellite.ZeroAddress.selector); - new PoaManagerSatellite(address(pm), mailbox, hubDomain, address(0)); + new BeaconProxy(address(satBeacon), badInit); } // ══════════════════════════════════════════════════════════ @@ -475,6 +490,26 @@ contract PoaManagerSatelliteTest is Test { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); satellite.adminCall(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 77)); } + + // ══════════════════════════════════════════════════════════ + // 33. Initialize reverts on zero owner + // ══════════════════════════════════════════════════════════ + + function testInitializeRevertsZeroOwner() public { + bytes memory badInit = + abi.encodeCall(PoaManagerSatellite.initialize, (address(0), address(pm), mailbox, hubDomain, hubAddr)); + vm.expectRevert(PoaManagerSatellite.ZeroAddress.selector); + new BeaconProxy(address(satBeacon), badInit); + } + + // ══════════════════════════════════════════════════════════ + // 34. Double initialization reverts + // ══════════════════════════════════════════════════════════ + + function testDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + satellite.initialize(address(this), address(pm), mailbox, hubDomain, hubAddr); + } } /// @dev Mock target that gates a function behind msg.sender == poaManager diff --git a/test/QuickJoin.t.sol b/test/QuickJoin.t.sol index 2a399a3..b70d40c 100644 --- a/test/QuickJoin.t.sol +++ b/test/QuickJoin.t.sol @@ -322,4 +322,49 @@ contract QuickJoinTest is Test { } event RegisterAndQuickJoined(address indexed user, string username, uint256[] hatIds); + + /* ═══════════════════ quickJoinForUser tests ═══════════════════ */ + + function testQuickJoinForUserByMasterDeploy() public { + registry.setUsername(user1, "bob"); + vm.prank(master); + qj.quickJoinForUser(user1); + assertTrue(mockExecutor.hats().isWearerOfHat(user1, DEFAULT_HAT_ID)); + } + + function testQuickJoinForUserByExecutor() public { + registry.setUsername(user1, "bob"); + vm.prank(address(mockExecutor)); + qj.quickJoinForUser(user1); + assertTrue(mockExecutor.hats().isWearerOfHat(user1, DEFAULT_HAT_ID)); + } + + function testQuickJoinForUserEmitsEvent() public { + registry.setUsername(user1, "bob"); + vm.prank(master); + vm.expectEmit(true, true, true, true); + uint256[] memory expectedHats = new uint256[](1); + expectedHats[0] = DEFAULT_HAT_ID; + emit QuickJoined(user1, expectedHats); + qj.quickJoinForUser(user1); + } + + function testQuickJoinForUserRevertsUnauthorized() public { + registry.setUsername(user1, "bob"); + vm.prank(user1); + vm.expectRevert(QuickJoin.OnlyMasterDeploy.selector); + qj.quickJoinForUser(user1); + } + + function testQuickJoinForUserRevertsNoUsername() public { + vm.prank(master); + vm.expectRevert(QuickJoin.NoUsername.selector); + qj.quickJoinForUser(user1); + } + + function testQuickJoinForUserRevertsZeroUser() public { + vm.prank(master); + vm.expectRevert(QuickJoin.ZeroUser.selector); + qj.quickJoinForUser(address(0)); + } } diff --git a/test/SatelliteOrgDeployment.t.sol b/test/SatelliteOrgDeployment.t.sol new file mode 100644 index 0000000..5f4c21f --- /dev/null +++ b/test/SatelliteOrgDeployment.t.sol @@ -0,0 +1,373 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; +import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; +import {Initializable} from "@openzeppelin-contracts-upgradeable/contracts/proxy/utils/Initializable.sol"; +import {NameClaimAdapter} from "../src/crosschain/NameClaimAdapter.sol"; +import {SatelliteOnboardingHelper, IPasskeyFactory} from "../src/crosschain/SatelliteOnboardingHelper.sol"; + +/* ═══════════════ Mock contracts ═══════════════ */ + +contract MockRelay { + mapping(bytes32 => bool) public confirmedOrgNames; + mapping(address => string) private _usernames; + + // Track calls for assertions + address public lastRegisteredUser; + string public lastRegisteredUsername; + bytes32 public lastReleasedNameHash; + + function setConfirmedOrgName(bytes32 nameHash, bool confirmed) external { + confirmedOrgNames[nameHash] = confirmed; + } + + string public lastClaimedOrgName; + + function dispatchOrgNameClaim(string calldata orgName) external { + lastClaimedOrgName = orgName; + } + + function dispatchOrgNameRelease(bytes32 nameHash) external { + lastReleasedNameHash = nameHash; + delete confirmedOrgNames[nameHash]; + } + + function registerAccountForUser(address user, string calldata username) external payable { + lastRegisteredUser = user; + lastRegisteredUsername = username; + } + + function registerAccount(address user, string calldata username, uint256, uint256, bytes calldata) + external + payable + { + lastRegisteredUser = user; + lastRegisteredUsername = username; + } + + function getUsername(address user) external view returns (string memory) { + return _usernames[user]; + } + + function setUsername(address user, string calldata name) external { + _usernames[user] = name; + } +} + +contract MockQuickJoin { + mapping(address => bool) public joined; + mapping(address => bool) public joinedNoUser; + + function quickJoinForUser(address user) external { + joined[user] = true; + } + + function quickJoinNoUserMasterDeploy(address newUser) external { + joinedNoUser[newUser] = true; + } +} + +contract MockPasskeyFactory { + address public lastCreatedAccount; + + /// @dev Returns a deterministic address based ONLY on the passkey params (not msg.sender), + /// so both the test and the helper get the same address for the same inputs. + function createAccount(bytes32 credentialId, bytes32 pubKeyX, bytes32 pubKeyY, uint256 salt) + external + returns (address account) + { + account = address(uint160(uint256(keccak256(abi.encodePacked(credentialId, pubKeyX, pubKeyY, salt))))); + lastCreatedAccount = account; + } +} + +/* ═══════════════ NameClaimAdapter Tests ═══════════════ */ + +contract NameClaimAdapterTest is Test { + NameClaimAdapter adapter; + MockRelay relay; + + address owner = address(this); + address orgRegistry = address(0xAA); + + function setUp() public { + relay = new MockRelay(); + + NameClaimAdapter impl = new NameClaimAdapter(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + adapter = NameClaimAdapter( + address(new BeaconProxy(address(beacon), abi.encodeCall(impl.initialize, (owner, address(relay))))) + ); + + adapter.setAuthorizedCaller(orgRegistry, true); + } + + function testClaimDispatchesOptimistically() public { + bytes32 nameHash = keccak256("MyOrg"); + + vm.prank(orgRegistry); + adapter.claimOrgNameLocal(nameHash, "MyOrg"); + + // Verify claim was dispatched to relay + assertEq(relay.lastClaimedOrgName(), "MyOrg"); + } + + function testChangeOrgNameSucceeds() public { + bytes32 oldHash = keccak256("OldOrg"); + bytes32 newHash = keccak256("NewOrg"); + + relay.setConfirmedOrgName(newHash, true); + + vm.prank(orgRegistry); + adapter.claimOrgNameLocal(oldHash, "OldOrg"); + + vm.prank(orgRegistry); + adapter.changeOrgNameLocal(oldHash, newHash); + + // Verify old name was released on relay + assertEq(relay.lastReleasedNameHash(), oldHash); + assertFalse(relay.confirmedOrgNames(oldHash)); + } + + function testChangeOrgNameUnconfirmedNewNameReverts() public { + bytes32 oldHash = keccak256("OldOrg"); + bytes32 newHash = keccak256("NewOrg"); + + vm.prank(orgRegistry); + adapter.claimOrgNameLocal(oldHash, "OldOrg"); + + vm.prank(orgRegistry); + vm.expectRevert(NameClaimAdapter.NameNotConfirmed.selector); + adapter.changeOrgNameLocal(oldHash, newHash); + } + + function testUnauthorizedCallerReverts() public { + bytes32 nameHash = keccak256("MyOrg"); + + vm.prank(address(0x999)); + vm.expectRevert(NameClaimAdapter.NotAuthorized.selector); + adapter.claimOrgNameLocal(nameHash, "MyOrg"); + } + + function testDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + adapter.initialize(owner, address(relay)); + } + + function testZeroAddressInitReverts() public { + NameClaimAdapter impl = new NameClaimAdapter(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + NameClaimAdapter tmp = NameClaimAdapter(address(new BeaconProxy(address(beacon), ""))); + + vm.expectRevert(NameClaimAdapter.ZeroAddress.selector); + tmp.initialize(address(0), address(relay)); + } + + function testRenounceOwnershipReverts() public { + vm.expectRevert(NameClaimAdapter.CannotRenounce.selector); + adapter.renounceOwnership(); + } + + function testSetAuthorizedCallerZeroAddressReverts() public { + vm.expectRevert(NameClaimAdapter.ZeroAddress.selector); + adapter.setAuthorizedCaller(address(0), true); + } +} + +/* ═══════════════ SatelliteOnboardingHelper Tests ═══════════════ */ + +contract SatelliteOnboardingHelperTest is Test { + SatelliteOnboardingHelper helper; + MockRelay relay; + MockQuickJoin quickJoin; + MockPasskeyFactory passkeyFactory; + + address owner = address(this); + address user1 = address(0x100); + + event RegisterAndJoined(address indexed user, string username); + event RegisterAndJoinedWithPasskey(address indexed account, bytes32 indexed credentialId, string username); + event JoinCompleted(address indexed user); + event JoinCompletedWithPasskey(address indexed account, bytes32 indexed credentialId); + + function setUp() public { + relay = new MockRelay(); + quickJoin = new MockQuickJoin(); + passkeyFactory = new MockPasskeyFactory(); + + SatelliteOnboardingHelper impl = new SatelliteOnboardingHelper(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + helper = SatelliteOnboardingHelper( + address( + new BeaconProxy( + address(beacon), + abi.encodeCall( + impl.initialize, (owner, address(relay), address(quickJoin), address(passkeyFactory)) + ) + ) + ) + ); + } + + /* ── Optimistic: registerAndJoin (EOA direct) ── */ + + function testRegisterAndJoin() public { + vm.prank(user1); + vm.expectEmit(true, true, true, true); + emit RegisterAndJoined(user1, "alice"); + helper.registerAndJoin("alice"); + + // Verify relay was called with correct user + assertEq(relay.lastRegisteredUser(), user1); + assertEq(relay.lastRegisteredUsername(), "alice"); + // Verify user joined immediately (no username check) + assertTrue(quickJoin.joinedNoUser(user1)); + } + + /* ── Optimistic: registerAndJoinSponsored (relayer path) ── */ + + function testRegisterAndJoinSponsored() public { + address relayer = address(0x200); + + vm.prank(relayer); + vm.expectEmit(true, true, true, true); + emit RegisterAndJoined(user1, "alice"); + helper.registerAndJoinSponsored(user1, "alice", block.timestamp + 1 hours, 0, "fakesig"); + + assertEq(relay.lastRegisteredUser(), user1); + assertEq(relay.lastRegisteredUsername(), "alice"); + assertTrue(quickJoin.joinedNoUser(user1)); + } + + /* ── Optimistic: registerAndJoinWithPasskey ── */ + + function testRegisterAndJoinWithPasskey() public { + SatelliteOnboardingHelper.PasskeyEnrollment memory passkey = _defaultPasskey(); + + address account = helper.registerAndJoinWithPasskey(passkey, "alice"); + + // Verify passkey account was created + assertEq(passkeyFactory.lastCreatedAccount(), account); + // Verify relay was called with passkey account address + assertEq(relay.lastRegisteredUser(), account); + assertEq(relay.lastRegisteredUsername(), "alice"); + // Verify account joined immediately + assertTrue(quickJoin.joinedNoUser(account)); + } + + function testRegisterAndJoinWithPasskeyRevertsNoFactory() public { + SatelliteOnboardingHelper noPasskeyHelper = _deployWithoutPasskey(); + + vm.expectRevert(SatelliteOnboardingHelper.PasskeyFactoryNotSet.selector); + noPasskeyHelper.registerAndJoinWithPasskey(_defaultPasskey(), "alice"); + } + + /* ── Non-optimistic: quickJoinWithUser ── */ + + function testQuickJoinWithUser() public { + relay.setUsername(user1, "alice"); + + vm.prank(user1); + vm.expectEmit(true, true, true, true); + emit JoinCompleted(user1); + helper.quickJoinWithUser(); + + assertTrue(quickJoin.joined(user1)); + } + + function testQuickJoinWithUserRevertsNoUsername() public { + vm.prank(user1); + vm.expectRevert(SatelliteOnboardingHelper.NoUsername.selector); + helper.quickJoinWithUser(); + } + + /* ── Non-optimistic: quickJoinWithPasskey ── */ + + function testQuickJoinWithPasskey() public { + SatelliteOnboardingHelper.PasskeyEnrollment memory passkey = _defaultPasskey(); + + // Compute the deterministic account address (same formula as MockPasskeyFactory) + address account = address( + uint160( + uint256( + keccak256( + abi.encodePacked(passkey.credentialId, passkey.publicKeyX, passkey.publicKeyY, passkey.salt) + ) + ) + ) + ); + + // Simulate username already confirmed for this passkey account + relay.setUsername(account, "alice"); + + address returned = helper.quickJoinWithPasskey(passkey); + + assertEq(returned, account); + assertTrue(quickJoin.joined(account)); + } + + function testQuickJoinWithPasskeyRevertsNoUsername() public { + vm.expectRevert(SatelliteOnboardingHelper.NoUsername.selector); + helper.quickJoinWithPasskey(_defaultPasskey()); + } + + function testQuickJoinWithPasskeyRevertsNoFactory() public { + SatelliteOnboardingHelper noPasskeyHelper = _deployWithoutPasskey(); + + vm.expectRevert(SatelliteOnboardingHelper.PasskeyFactoryNotSet.selector); + noPasskeyHelper.quickJoinWithPasskey(_defaultPasskey()); + } + + /* ── Admin / Init ── */ + + function testDoubleInitializeReverts() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + helper.initialize(owner, address(relay), address(quickJoin), address(passkeyFactory)); + } + + function testZeroAddressInitReverts() public { + SatelliteOnboardingHelper impl = new SatelliteOnboardingHelper(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + SatelliteOnboardingHelper tmp = SatelliteOnboardingHelper(address(new BeaconProxy(address(beacon), ""))); + + vm.expectRevert(SatelliteOnboardingHelper.ZeroAddress.selector); + tmp.initialize(address(0), address(relay), address(quickJoin), address(passkeyFactory)); + } + + function testRenounceOwnershipReverts() public { + vm.expectRevert(SatelliteOnboardingHelper.CannotRenounce.selector); + helper.renounceOwnership(); + } + + function testGetters() public view { + assertEq(address(helper.relay()), address(relay)); + assertEq(address(helper.quickJoin()), address(quickJoin)); + assertEq(address(helper.passkeyFactory()), address(passkeyFactory)); + } + + /* ── Test Helpers ── */ + + function _defaultPasskey() internal pure returns (SatelliteOnboardingHelper.PasskeyEnrollment memory) { + return SatelliteOnboardingHelper.PasskeyEnrollment({ + credentialId: bytes32(uint256(1)), + publicKeyX: bytes32(uint256(2)), + publicKeyY: bytes32(uint256(3)), + salt: 42 + }); + } + + function _deployWithoutPasskey() internal returns (SatelliteOnboardingHelper) { + SatelliteOnboardingHelper impl = new SatelliteOnboardingHelper(); + UpgradeableBeacon beacon = new UpgradeableBeacon(address(impl), address(this)); + return SatelliteOnboardingHelper( + address( + new BeaconProxy( + address(beacon), + abi.encodeCall(impl.initialize, (owner, address(relay), address(quickJoin), address(0))) + ) + ) + ); + } +} diff --git a/test/mocks/StoringMailbox.sol b/test/mocks/StoringMailbox.sol new file mode 100644 index 0000000..faa3aeb --- /dev/null +++ b/test/mocks/StoringMailbox.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title StoringMailbox +/// @notice Mock mailbox that stores dispatched messages WITHOUT auto-delivering. +/// Use for testing contracts that dispatch responses in handle() callbacks +/// where auto-delivery would fail due to cross-chain mailbox mismatch. +contract StoringMailbox { + uint32 public localDomain; + uint256 public messageCount; + + struct DispatchedMessage { + uint32 destinationDomain; + bytes32 recipientAddress; + bytes messageBody; + } + + DispatchedMessage[] public dispatched; + + constructor(uint32 _localDomain) { + localDomain = _localDomain; + } + + function dispatch(uint32 destinationDomain, bytes32 recipientAddress, bytes calldata messageBody) + external + payable + returns (bytes32) + { + dispatched.push( + DispatchedMessage({ + destinationDomain: destinationDomain, recipientAddress: recipientAddress, messageBody: messageBody + }) + ); + + unchecked { + ++messageCount; + } + return keccak256(abi.encodePacked(messageCount, destinationDomain, recipientAddress)); + } + + function dispatchedCount() external view returns (uint256) { + return dispatched.length; + } + + function getDispatched(uint256 index) external view returns (DispatchedMessage memory) { + return dispatched[index]; + } +}