diff --git a/foundry.toml b/foundry.toml index 131d353..aedd1fb 100644 --- a/foundry.toml +++ b/foundry.toml @@ -23,7 +23,7 @@ evm_version = "cancun" [rpc_endpoints] # Testnets (public dRPC endpoints - no API key needed) -sepolia = "wss://sepolia.gateway.tenderly.co" +sepolia = "https://sepolia.drpc.org" holesky = "https://holesky.drpc.org" optimism-sepolia = "https://optimism-sepolia.drpc.org" base-sepolia = "https://base-sepolia.drpc.org" diff --git a/script/MainDeploy.s.sol b/script/MainDeploy.s.sol index 242a043..6ecd68f 100644 --- a/script/MainDeploy.s.sol +++ b/script/MainDeploy.s.sol @@ -61,10 +61,6 @@ import {RoleConfigStructs} from "../src/libs/RoleConfigStructs.sol"; contract DeployHomeChain is DeployHelper { /*═══════════════════════════ CONSTANTS ═══════════════════════════*/ - 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"); /*═══════════════════════════ RESULT STRUCTS ═══════════════════════════*/ @@ -197,7 +193,8 @@ contract DeployHomeChain is DeployHelper { "initialize(address,address,address)", ENTRY_POINT_V07, HATS_PROTOCOL, infra.poaManager ); infra.paymasterHub = address(new BeaconProxy(paymasterHubBeacon, paymasterHubInit)); - PaymasterHub(payable(infra.paymasterHub)).donateToSolidarity{value: INITIAL_SOLIDARITY_FUND}(); + uint256 solidarityFund = vm.envOr("SOLIDARITY_FUND", INITIAL_SOLIDARITY_FUND); + PaymasterHub(payable(infra.paymasterHub)).donateToSolidarity{value: solidarityFund}(); console.log("PaymasterHub:", infra.paymasterHub); // Deploy OrgDeployer proxy @@ -252,7 +249,10 @@ contract DeployHomeChain is DeployHelper { ); // Wire up universal factory to GlobalAccountRegistry (owner = deployer) UniversalAccountRegistry(infra.globalAccountRegistry).setPasskeyFactory(infra.universalPasskeyFactory); + // Transfer ownership to PoaManager so governance can manage via adminCall + UniversalAccountRegistry(infra.globalAccountRegistry).transferOwnership(infra.poaManager); console.log("UniversalPasskeyFactory:", infra.universalPasskeyFactory); + console.log("GlobalAccountRegistry ownership -> PoaManager"); // Register infrastructure for subgraph indexing pm.registerInfrastructure( @@ -472,6 +472,18 @@ contract DeployHomeChain is DeployHelper { contract DeploySatellite is DeployHelper { bytes32 public constant DD_SALT = keccak256("POA_DETERMINISTIC_DEPLOYER_V1"); + struct SatelliteInfraResult { + address orgRegistry; + address orgDeployer; + address paymasterHub; + address globalAccountRegistry; + address universalPasskeyFactory; + address governanceFactory; + address accessFactory; + address modulesFactory; + address hatsTreeSetup; + } + function run() public { uint256 deployerKey = vm.envUint("PRIVATE_KEY"); address mailboxAddr = vm.envAddress("MAILBOX"); @@ -490,6 +502,10 @@ contract DeploySatellite is DeployHelper { vm.startBroadcast(deployerKey); + // Verify external contracts exist on this chain + require(HATS_PROTOCOL.code.length > 0, "Hats Protocol not deployed on this chain"); + require(ENTRY_POINT_V07.code.length > 0, "EntryPoint v0.7 not deployed on this chain"); + // 1. Deploy DeterministicDeployer if needed address ddAddr = _deployDeterministicDeployer(deployer); DeterministicDeployer dd = DeterministicDeployer(ddAddr); @@ -505,36 +521,150 @@ contract DeploySatellite is DeployHelper { pm.updateImplRegistry(address(reg)); reg.registerImplementation("ImplementationRegistry", "v1", address(regImpl), true); reg.transferOwnership(address(pm)); + console.log("ImplementationRegistry:", address(reg)); + + // 3. Deploy infrastructure types via DD and register on PoaManager + _deployAndRegisterInfraTypesDD(pm, dd); - // 3. Deploy implementations via DD and register all contract types + // 4. Deploy application types via DD and register _deployAndRegisterTypesDD(pm, dd); - // 4. Deploy PoaManagerSatellite + // 5. Deploy full infrastructure + SatelliteInfraResult memory infra = _deploySatelliteInfrastructure(pm, deployer); + + // 6. Register infrastructure for subgraph indexing + pm.registerInfrastructure( + infra.orgDeployer, + infra.orgRegistry, + address(reg), + infra.paymasterHub, + infra.globalAccountRegistry, + infra.universalPasskeyFactory + ); + console.log("Infrastructure registered for subgraph indexing"); + + // 7. Deploy PoaManagerSatellite (AFTER all wiring) PoaManagerSatellite satellite = new PoaManagerSatellite(address(pm), mailboxAddr, hubDomain, hubAddress); + console.log("PoaManagerSatellite:", address(satellite)); - // 5. Transfer PoaManager ownership to Satellite + // 8. Transfer PoaManager ownership to Satellite (MUST BE LAST) pm.transferOwnership(address(satellite)); + console.log("PoaManager ownership transferred to Satellite"); vm.stopBroadcast(); + // Write state + _writeSatelliteState(pm, reg, satellite, infra); + console.log("\n=== Satellite Deployment Complete ==="); - console.log("PoaManager:", address(pm)); - console.log("ImplementationRegistry:", address(reg)); - console.log("PoaManagerSatellite:", address(satellite)); + } + + function _deploySatelliteInfrastructure(PoaManager pm, address deployer) + internal + returns (SatelliteInfraResult memory infra) + { + // --- Deploy stateless factories --- + infra.governanceFactory = address(new GovernanceFactory()); + infra.accessFactory = address(new AccessFactory()); + infra.modulesFactory = address(new ModulesFactory()); + infra.hatsTreeSetup = address(new HatsTreeSetup()); + console.log("Factories deployed"); + + // --- OrgRegistry proxy --- + address orgRegBeacon = pm.getBeaconById(keccak256("OrgRegistry")); + bytes memory orgRegInit = abi.encodeWithSignature("initialize(address,address)", deployer, HATS_PROTOCOL); + infra.orgRegistry = address(new BeaconProxy(orgRegBeacon, orgRegInit)); + console.log("OrgRegistry:", infra.orgRegistry); + + // --- PaymasterHub proxy --- + address paymasterHubBeacon = pm.getBeaconById(keccak256("PaymasterHub")); + bytes memory paymasterHubInit = + abi.encodeWithSignature("initialize(address,address,address)", ENTRY_POINT_V07, HATS_PROTOCOL, address(pm)); + infra.paymasterHub = address(new BeaconProxy(paymasterHubBeacon, paymasterHubInit)); + uint256 solidarityFund = vm.envOr("SOLIDARITY_FUND", INITIAL_SOLIDARITY_FUND); + PaymasterHub(payable(infra.paymasterHub)).donateToSolidarity{value: solidarityFund}(); + console.log("PaymasterHub:", infra.paymasterHub); + + // --- OrgDeployer proxy --- + address deployerBeacon = pm.getBeaconById(keccak256("OrgDeployer")); + bytes memory orgDeployerInit = abi.encodeWithSignature( + "initialize(address,address,address,address,address,address,address,address)", + infra.governanceFactory, + infra.accessFactory, + infra.modulesFactory, + address(pm), + infra.orgRegistry, + HATS_PROTOCOL, + infra.hatsTreeSetup, + infra.paymasterHub + ); + infra.orgDeployer = address(new BeaconProxy(deployerBeacon, orgDeployerInit)); + console.log("OrgDeployer:", infra.orgDeployer); + + // --- Wire OrgRegistry ownership to OrgDeployer --- + OrgRegistry(infra.orgRegistry).transferOwnership(infra.orgDeployer); + + // --- Authorize OrgDeployer as org registrar on PaymasterHub --- + pm.adminCall(infra.paymasterHub, abi.encodeWithSignature("setOrgRegistrar(address)", infra.orgDeployer)); + console.log("OrgDeployer authorized as orgRegistrar on PaymasterHub"); + + // --- Deploy GlobalAccountRegistry --- + address accRegBeacon = pm.getBeaconById(keccak256("UniversalAccountRegistry")); + bytes memory accRegInit = abi.encodeWithSignature("initialize(address)", deployer); + infra.globalAccountRegistry = address(new BeaconProxy(accRegBeacon, accRegInit)); + console.log("GlobalAccountRegistry:", infra.globalAccountRegistry); - // 7. Write satellite info for manual addition to state file + // --- Deploy UniversalPasskeyFactory --- + address passkeyAccountBeacon = pm.getBeaconById(keccak256("PasskeyAccount")); + address passkeyFactoryBeacon = pm.getBeaconById(keccak256("PasskeyAccountFactory")); + bytes memory passkeyFactoryInit = abi.encodeWithSignature( + "initialize(address,address,address,uint48)", + address(pm), + passkeyAccountBeacon, + POA_GUARDIAN, + uint48(7 days) + ); + infra.universalPasskeyFactory = address(new BeaconProxy(passkeyFactoryBeacon, passkeyFactoryInit)); + console.log("UniversalPasskeyFactory:", infra.universalPasskeyFactory); + + // --- Wire passkey factory to OrgDeployer --- + pm.adminCall( + infra.orgDeployer, + abi.encodeWithSignature("setUniversalPasskeyFactory(address)", infra.universalPasskeyFactory) + ); + + // --- Wire passkey factory to GlobalAccountRegistry --- + UniversalAccountRegistry(infra.globalAccountRegistry).setPasskeyFactory(infra.universalPasskeyFactory); + // Transfer ownership to PoaManager so home chain governance can manage via adminCall + UniversalAccountRegistry(infra.globalAccountRegistry).transferOwnership(address(pm)); + console.log("GlobalAccountRegistry ownership -> PoaManager"); + + console.log("--- Satellite Infrastructure Complete ---"); + } + + function _writeSatelliteState( + PoaManager pm, + ImplementationRegistry reg, + PoaManagerSatellite satellite, + SatelliteInfraResult memory infra + ) internal { uint32 satDomain = uint32(vm.envUint("SATELLITE_DOMAIN")); - console.log("\nAdd to main-deploy-state.json satellites array:"); - console.log(' { "domain":', satDomain, ","); - console.log(' "satellite": "', address(satellite), '",'); - console.log(' "poaManager": "', address(pm), '" }'); - // Write satellite-specific state file string memory satObj = "satellite_state"; vm.serializeUint(satObj, "domain", uint256(satDomain)); vm.serializeAddress(satObj, "satellite", address(satellite)); vm.serializeAddress(satObj, "poaManager", address(pm)); - string memory satJson = vm.serializeAddress(satObj, "implRegistry", address(reg)); + vm.serializeAddress(satObj, "implRegistry", address(reg)); + vm.serializeAddress(satObj, "orgRegistry", infra.orgRegistry); + vm.serializeAddress(satObj, "orgDeployer", infra.orgDeployer); + vm.serializeAddress(satObj, "paymasterHub", infra.paymasterHub); + vm.serializeAddress(satObj, "globalAccountRegistry", infra.globalAccountRegistry); + vm.serializeAddress(satObj, "universalPasskeyFactory", infra.universalPasskeyFactory); + vm.serializeAddress(satObj, "governanceFactory", infra.governanceFactory); + vm.serializeAddress(satObj, "accessFactory", infra.accessFactory); + vm.serializeAddress(satObj, "modulesFactory", infra.modulesFactory); + string memory satJson = vm.serializeAddress(satObj, "hatsTreeSetup", infra.hatsTreeSetup); + string memory filename = string.concat("script/satellite-state-", vm.toString(uint256(satDomain)), ".json"); vm.writeJson(satJson, filename); console.log("Satellite state written to", filename); diff --git a/script/deploy-testnet.sh b/script/deploy-testnet.sh new file mode 100755 index 0000000..1273f9f --- /dev/null +++ b/script/deploy-testnet.sh @@ -0,0 +1,553 @@ +#!/usr/bin/env bash +set -euo pipefail + +############################################################################# +# deploy-testnet.sh - Full cross-chain protocol deployment on testnets +# +# Orchestrates MainDeploy.s.sol across 2 chains: +# Home: Sepolia +# Satellite: Base Sepolia +# +# Prerequisites: +# - .env with DEPLOYER_PRIVATE_KEY (funded on both Sepolia and Base Sepolia) +# - jq (for JSON parsing: brew install jq) +# - foundry.toml with RPC endpoints configured +# +# Usage: +# ./script/deploy-testnet.sh # Full deployment (all 4 steps) +# ./script/deploy-testnet.sh --step 1 # Deploy home chain only +# ./script/deploy-testnet.sh --step 2 # Deploy satellite +# ./script/deploy-testnet.sh --step 3 # Register satellite + transfer ownership +# ./script/deploy-testnet.sh --step 4 # Verify deployment +# ./script/deploy-testnet.sh --step summary # Print deployed addresses +# ./script/deploy-testnet.sh --dry-run # Simulate without broadcasting +# ./script/deploy-testnet.sh --yes # Skip confirmation prompt +############################################################################# + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +STATE_FILE="$SCRIPT_DIR/main-deploy-state.json" + +# ═══════════════════════════ Chain Configuration ═══════════════════════════ + +# Home Chain: Sepolia +HOME_RPC="sepolia" +HOME_DOMAIN=11155111 +HOME_MAILBOX="0xfFAEF09B3cd11D9b20d1a19bECca54EEC2884766" + +# Satellite Chain: Base Sepolia +SAT_NAMES=("Base Sepolia") +SAT_RPCS=("base-sepolia") +SAT_DOMAINS=(84532) +SAT_MAILBOXES=( + "0x6966b0E55883d49BFB24539356a2f8A673E02039" +) + +# ═══════════════════════════ Output Helpers ═══════════════════════════ + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } + +header() { + echo "" + echo -e "${BLUE}════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} $*${NC}" + echo -e "${BLUE}════════════════════════════════════════════════════════════${NC}" + echo "" +} + +# ═══════════════════════════ JSON Helper ═══════════════════════════ + +json_get() { + jq -r ".$2" "$1" +} + +# ═══════════════════════════ Argument Parsing ═══════════════════════════ + +STEP="" +DRY_RUN=false +SKIP_CONFIRM=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --step) STEP="$2"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + --yes|-y) SKIP_CONFIRM=true; shift ;; + --help|-h) + echo "Usage: ./script/deploy-testnet.sh [OPTIONS]" + echo "" + echo "Deploys full POA protocol on Sepolia (home) + Base Sepolia (satellite)." + echo "" + echo "Steps:" + echo " 1 Deploy home chain infrastructure + governance org (Sepolia)" + echo " 2 Deploy satellite infrastructure (Base Sepolia)" + echo " 3 Register satellite on Hub + transfer ownership to governance" + echo " 4 Verify deployment (read-only)" + echo "" + echo "Options:" + echo " --step N Run only step N (1-4, or 'summary')" + echo " --dry-run Simulate without broadcasting transactions" + echo " --yes, -y Skip confirmation prompt" + echo " --help, -h Show this help" + exit 0 + ;; + *) error "Unknown option: $1"; exit 1 ;; + esac +done + +# ═══════════════════════════ Environment ═══════════════════════════ + +load_env() { + if [ -f "$PROJECT_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$PROJECT_DIR/.env" + set +a + fi + + # Prevent .env from overriding chain-specific vars managed by this script + unset MAILBOX SATELLITE_DOMAIN HUB_DOMAIN 2>/dev/null || true + + # Support both PRIVATE_KEY and DEPLOYER_PRIVATE_KEY + if [ -z "${PRIVATE_KEY:-}" ] && [ -n "${DEPLOYER_PRIVATE_KEY:-}" ]; then + export PRIVATE_KEY="$DEPLOYER_PRIVATE_KEY" + fi + + if [ -z "${PRIVATE_KEY:-}" ]; then + error "PRIVATE_KEY (or DEPLOYER_PRIVATE_KEY) not set. Add it to .env" + exit 1 + fi +} + +# ═══════════════════════════ Error Handling ═══════════════════════════ + +CURRENT_STEP="" +CURRENT_STEP_NUM="" + +cleanup() { + local exit_code=$? + if [ $exit_code -ne 0 ] && [ -n "${CURRENT_STEP:-}" ]; then + echo "" + error "Failed during: ${CURRENT_STEP}" + echo "" + if [ -n "${CURRENT_STEP_NUM:-}" ]; then + error "To resume, run:" + error " ./script/deploy-testnet.sh --step $CURRENT_STEP_NUM" + fi + fi +} +trap cleanup EXIT + +# ═══════════════════════════ Pre-flight Checks ═══════════════════════════ + +preflight_checks() { + header "Pre-flight Checks" + + if ! command -v forge &>/dev/null; then + error "forge not found. Install Foundry first." + exit 1 + fi + if ! command -v cast &>/dev/null; then + error "cast not found. Install Foundry first." + exit 1 + fi + if ! command -v jq &>/dev/null; then + error "jq required for JSON parsing. Install with: brew install jq" + exit 1 + fi + + # Derive deployer address + DEPLOYER_ADDRESS=$(cast wallet address "$PRIVATE_KEY" 2>/dev/null) || { + error "Invalid PRIVATE_KEY. Could not derive address." + exit 1 + } + info "Deployer: $DEPLOYER_ADDRESS" + echo "" + + # Check RPC connectivity and balances + local all_rpcs=("$HOME_RPC" "${SAT_RPCS[@]}") + local all_names=("Sepolia (home)" "${SAT_NAMES[@]}") + + for i in "${!all_rpcs[@]}"; do + local rpc="${all_rpcs[$i]}" + local name="${all_names[$i]}" + + local balance + balance=$(cast balance "$DEPLOYER_ADDRESS" --rpc-url "$rpc" 2>/dev/null) || { + error "Cannot reach $name (--rpc-url $rpc). Check foundry.toml." + exit 1 + } + local eth_balance + eth_balance=$(cast from-wei "$balance" 2>/dev/null || echo "?") + info "$name: $eth_balance ETH" + done + + echo "" + + # Verify external contracts exist on Sepolia + info "Checking external contracts on Sepolia..." + local hats_code + hats_code=$(cast code 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137 --rpc-url sepolia 2>/dev/null) + if [ "$hats_code" = "0x" ] || [ -z "$hats_code" ]; then + error "Hats Protocol not deployed on Sepolia at 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137" + exit 1 + fi + success "Hats Protocol: deployed" + + local ep_code + ep_code=$(cast code 0x0000000071727De22E5E9d8BAf0edAc6f37da032 --rpc-url sepolia 2>/dev/null) + if [ "$ep_code" = "0x" ] || [ -z "$ep_code" ]; then + error "EntryPoint v0.7 not deployed on Sepolia" + exit 1 + fi + success "EntryPoint v0.7: deployed" + + echo "" + + # Verify external contracts exist on Base Sepolia + info "Checking external contracts on Base Sepolia..." + local sat_hats_code + sat_hats_code=$(cast code 0x3bc1A0Ad72417f2d411118085256fC53CBdDd137 --rpc-url base-sepolia 2>/dev/null) + if [ "$sat_hats_code" = "0x" ] || [ -z "$sat_hats_code" ]; then + error "Hats Protocol not deployed on Base Sepolia" + exit 1 + fi + success "Hats Protocol on Base Sepolia: deployed" + + local sat_ep_code + sat_ep_code=$(cast code 0x0000000071727De22E5E9d8BAf0edAc6f37da032 --rpc-url base-sepolia 2>/dev/null) + if [ "$sat_ep_code" = "0x" ] || [ -z "$sat_ep_code" ]; then + error "EntryPoint v0.7 not deployed on Base Sepolia" + exit 1 + fi + success "EntryPoint v0.7 on Base Sepolia: deployed" + + echo "" + + # Build with production profile (via_ir + optimizer to stay under contract size limit) + # --skip test avoids Yul optimizer stack-too-deep in DeployerTest.t.sol + info "Building contracts (FOUNDRY_PROFILE=production)..." + FOUNDRY_PROFILE=production forge build --skip test --silent + success "Build complete." +} + +preflight_checks_minimal() { + header "Pre-flight Checks (read-only)" + + if ! command -v forge &>/dev/null; then + error "forge not found. Install Foundry first." + exit 1 + fi + + cast chain-id --rpc-url "$HOME_RPC" &>/dev/null || { + error "Cannot reach home chain (--rpc-url $HOME_RPC). Check foundry.toml." + exit 1 + } + success "Sepolia RPC reachable." +} + +# ═══════════════════════════ Confirmation ═══════════════════════════ + +confirm_deployment() { + if [ "$SKIP_CONFIRM" = true ]; then return; fi + if [ "$DRY_RUN" = true ]; then + info "Dry run mode — no transactions will be broadcast." + return + fi + + echo "" + warn "This will broadcast transactions on TESTNET chains:" + echo " Sepolia (home chain)" + echo " Base Sepolia (satellite)" + echo "" + echo " Deployer: $DEPLOYER_ADDRESS" + echo "" + read -rp " Type 'yes' to continue: " confirm + if [ "$confirm" != "yes" ]; then + echo "Aborted." + exit 0 + fi +} + +# ═══════════════════════════ Forge Runner ═══════════════════════════ + +run_forge_script() { + local contract="$1" + local rpc="$2" + shift 2 + + local broadcast_flag="--broadcast" + if [ "$DRY_RUN" = true ]; then + broadcast_flag="" + fi + + # shellcheck disable=SC2086 + FOUNDRY_PROFILE=production \ + forge script "script/MainDeploy.s.sol:${contract}" \ + --rpc-url "$rpc" \ + --skip test \ + $broadcast_flag \ + --slow \ + "$@" +} + +# ═══════════════════════════ Step 1: Home Chain ═══════════════════════════ + +step1_deploy_home() { + CURRENT_STEP="Deploy Home Chain (Sepolia)" + CURRENT_STEP_NUM=1 + header "Step 1: Deploy Home Chain (Sepolia, domain $HOME_DOMAIN)" + + if [ -f "$STATE_FILE" ]; then + warn "State file already exists: $STATE_FILE" + warn "Home chain may already be deployed." + if [ "$SKIP_CONFIRM" != true ]; then + read -rp " Overwrite and redeploy? (yes/no): " confirm + if [ "$confirm" != "yes" ]; then + info "Skipping step 1. Using existing state." + return + fi + fi + fi + + MAILBOX="$HOME_MAILBOX" \ + HUB_DOMAIN="$HOME_DOMAIN" \ + run_forge_script DeployHomeChain "$HOME_RPC" + + if [ ! -f "$STATE_FILE" ]; then + error "State file not created: $STATE_FILE" + exit 1 + fi + + echo "" + success "Home chain deployed." + info " Hub: $(json_get "$STATE_FILE" "homeChain.hub")" + info " PoaManager: $(json_get "$STATE_FILE" "homeChain.poaManager")" + info " Executor: $(json_get "$STATE_FILE" "homeChain.governance.executor")" + info " GovernanceFactory: $(json_get "$STATE_FILE" "homeChain.governanceFactory")" + info " AccessFactory: $(json_get "$STATE_FILE" "homeChain.accessFactory")" + info " ModulesFactory: $(json_get "$STATE_FILE" "homeChain.modulesFactory")" +} + +# ═══════════════════════════ Step 2: Satellite ═══════════════════════════ + +step2_deploy_satellite() { + CURRENT_STEP="Deploy Satellite (Base Sepolia)" + CURRENT_STEP_NUM=2 + + if [ ! -f "$STATE_FILE" ]; then + error "Home chain state not found: $STATE_FILE" + error "Run step 1 first: ./script/deploy-testnet.sh --step 1" + exit 1 + fi + + local domain="${SAT_DOMAINS[0]}" + local rpc="${SAT_RPCS[0]}" + local mailbox="${SAT_MAILBOXES[0]}" + local sat_state_file="$SCRIPT_DIR/satellite-state-${domain}.json" + + if [ -f "$sat_state_file" ]; then + warn "Base Sepolia satellite state already exists: $sat_state_file" + if [ "$SKIP_CONFIRM" != true ]; then + read -rp " Redeploy? (yes/no): " confirm + if [ "$confirm" != "yes" ]; then + info "Skipping satellite deployment." + return + fi + fi + fi + + header "Step 2: Deploy Satellite on Base Sepolia (domain $domain)" + + MAILBOX="$mailbox" \ + SATELLITE_DOMAIN="$domain" \ + SOLIDARITY_FUND="20000000000000000" \ + run_forge_script DeploySatellite "$rpc" + + if [ ! -f "$sat_state_file" ]; then + error "Satellite state file not created: $sat_state_file" + exit 1 + fi + + echo "" + success "Base Sepolia satellite deployed." + info " Satellite: $(json_get "$sat_state_file" "satellite")" + info " PoaManager: $(json_get "$sat_state_file" "poaManager")" + info " GovernanceFactory: $(json_get "$sat_state_file" "governanceFactory")" + info " AccessFactory: $(json_get "$sat_state_file" "accessFactory")" + info " ModulesFactory: $(json_get "$sat_state_file" "modulesFactory")" +} + +# ═══════════════════════════ Step 3: Register & Transfer ═══════════════════════════ + +step3_register_and_transfer() { + CURRENT_STEP="Register Satellite & Transfer Ownership" + CURRENT_STEP_NUM=3 + header "Step 3: Register Satellite & Transfer Hub Ownership" + + if [ ! -f "$STATE_FILE" ]; then + error "Home chain state not found: $STATE_FILE" + error "Run step 1 first." + exit 1 + fi + + local domain="${SAT_DOMAINS[0]}" + local sat_file="$SCRIPT_DIR/satellite-state-${domain}.json" + if [ ! -f "$sat_file" ]; then + error "Missing satellite state: $sat_file" + error "Run step 2 first: ./script/deploy-testnet.sh --step 2" + exit 1 + fi + + SATELLITE_DOMAIN_0="$domain" \ + NUM_SATELLITES=1 \ + run_forge_script RegisterAndTransfer "$HOME_RPC" + + echo "" + success "Satellite registered and Hub ownership transferred to Executor." +} + +# ═══════════════════════════ Step 4: Verify ═══════════════════════════ + +step4_verify() { + CURRENT_STEP="Verify Deployment" + CURRENT_STEP_NUM=4 + header "Step 4: Verify Deployment" + + if [ ! -f "$STATE_FILE" ]; then + error "Home chain state not found: $STATE_FILE" + exit 1 + fi + + FOUNDRY_PROFILE=production \ + forge script script/MainDeploy.s.sol:VerifyDeployment \ + --rpc-url "$HOME_RPC" \ + --skip test + + echo "" + success "Verification complete." +} + +# ═══════════════════════════ Summary ═══════════════════════════ + +print_summary() { + header "Deployment Summary" + + if [ ! -f "$STATE_FILE" ]; then + error "No state file found: $STATE_FILE" + exit 1 + fi + + echo "Home Chain: Sepolia (domain $HOME_DOMAIN)" + echo " DeterministicDeployer: $(json_get "$STATE_FILE" "deterministicDeployer")" + echo " PoaManager: $(json_get "$STATE_FILE" "homeChain.poaManager")" + echo " PoaManagerHub: $(json_get "$STATE_FILE" "homeChain.hub")" + echo " ImplRegistry: $(json_get "$STATE_FILE" "homeChain.implRegistry")" + echo " OrgRegistry: $(json_get "$STATE_FILE" "homeChain.orgRegistry")" + echo " OrgDeployer: $(json_get "$STATE_FILE" "homeChain.orgDeployer")" + echo " PaymasterHub: $(json_get "$STATE_FILE" "homeChain.paymasterHub")" + echo " AccountRegistry: $(json_get "$STATE_FILE" "homeChain.globalAccountRegistry")" + echo " PasskeyFactory: $(json_get "$STATE_FILE" "homeChain.universalPasskeyFactory")" + echo "" + echo " Factories:" + echo " GovernanceFactory: $(json_get "$STATE_FILE" "homeChain.governanceFactory")" + echo " AccessFactory: $(json_get "$STATE_FILE" "homeChain.accessFactory")" + echo " ModulesFactory: $(json_get "$STATE_FILE" "homeChain.modulesFactory")" + echo " HatsTreeSetup: $(json_get "$STATE_FILE" "homeChain.hatsTreeSetup")" + echo "" + echo " Governance Org (Poa):" + echo " Executor: $(json_get "$STATE_FILE" "homeChain.governance.executor")" + echo " HybridVoting: $(json_get "$STATE_FILE" "homeChain.governance.hybridVoting")" + echo " DDVoting: $(json_get "$STATE_FILE" "homeChain.governance.directDemocracyVoting")" + echo " QuickJoin: $(json_get "$STATE_FILE" "homeChain.governance.quickJoin")" + echo " ParticipationToken: $(json_get "$STATE_FILE" "homeChain.governance.participationToken")" + echo " TaskManager: $(json_get "$STATE_FILE" "homeChain.governance.taskManager")" + echo " EducationHub: $(json_get "$STATE_FILE" "homeChain.governance.educationHub")" + echo " PaymentManager: $(json_get "$STATE_FILE" "homeChain.governance.paymentManager")" + echo "" + + local domain="${SAT_DOMAINS[0]}" + local sat_file="$SCRIPT_DIR/satellite-state-${domain}.json" + if [ -f "$sat_file" ]; then + echo "Satellite: Base Sepolia (domain $domain)" + echo " PoaManagerSatellite: $(json_get "$sat_file" "satellite")" + echo " PoaManager: $(json_get "$sat_file" "poaManager")" + echo " ImplRegistry: $(json_get "$sat_file" "implRegistry")" + echo " OrgRegistry: $(json_get "$sat_file" "orgRegistry")" + echo " OrgDeployer: $(json_get "$sat_file" "orgDeployer")" + echo " PaymasterHub: $(json_get "$sat_file" "paymasterHub")" + echo " AccountRegistry: $(json_get "$sat_file" "globalAccountRegistry")" + echo " PasskeyFactory: $(json_get "$sat_file" "universalPasskeyFactory")" + echo "" + echo " Factories:" + echo " GovernanceFactory: $(json_get "$sat_file" "governanceFactory")" + echo " AccessFactory: $(json_get "$sat_file" "accessFactory")" + echo " ModulesFactory: $(json_get "$sat_file" "modulesFactory")" + echo " HatsTreeSetup: $(json_get "$sat_file" "hatsTreeSetup")" + echo "" + else + warn "Satellite: state file not found ($sat_file)" + fi + + echo "State files:" + info " $STATE_FILE" + if [ -f "$sat_file" ]; then + info " $sat_file" + fi +} + +# ═══════════════════════════ Main ═══════════════════════════ + +main() { + header "POA Protocol Testnet Deployment" + echo " Home: Sepolia (domain $HOME_DOMAIN)" + echo " Satellite: Base Sepolia (domain ${SAT_DOMAINS[0]})" + + if [ "$STEP" = "summary" ]; then + print_summary + echo "" + success "Done." + return + fi + + load_env + + if [ "$STEP" = "4" ]; then + preflight_checks_minimal + step4_verify + echo "" + success "Done." + return + fi + + preflight_checks + + if [ -z "$STEP" ]; then + confirm_deployment + step1_deploy_home + step2_deploy_satellite + step3_register_and_transfer + step4_verify + print_summary + else + case "$STEP" in + 1) confirm_deployment; step1_deploy_home ;; + 2) confirm_deployment; step2_deploy_satellite ;; + 3) confirm_deployment; step3_register_and_transfer ;; + *) error "Unknown step: $STEP (valid: 1, 2, 3, 4, summary)"; exit 1 ;; + esac + fi + + echo "" + success "Done." +} + +main diff --git a/script/deploy.sh b/script/deploy.sh index caa081b..694b4da 100755 --- a/script/deploy.sh +++ b/script/deploy.sh @@ -226,7 +226,7 @@ preflight_checks() { fi # Derive deployer address (pipe key through stdin to avoid exposure in process list) - DEPLOYER_ADDRESS=$(echo "$PRIVATE_KEY" | cast wallet address --stdin 2>/dev/null) || { + DEPLOYER_ADDRESS=$(cast wallet address "$PRIVATE_KEY" 2>/dev/null) || { error "Invalid PRIVATE_KEY. Could not derive address." exit 1 } @@ -255,7 +255,7 @@ preflight_checks() { # Build with production profile info "Building contracts (FOUNDRY_PROFILE=production)..." - FOUNDRY_PROFILE=production forge build --silent 2>/dev/null || FOUNDRY_PROFILE=production forge build --silent + FOUNDRY_PROFILE=production forge build --skip test --silent 2>/dev/null || FOUNDRY_PROFILE=production forge build --skip test --silent success "Build complete." } @@ -325,6 +325,7 @@ run_forge_script() { --rpc-url "$rpc" \ $broadcast_flag \ --slow \ + --skip test \ $verify_flags \ "$@" } @@ -478,8 +479,10 @@ step4_verify() { exit 1 fi + FOUNDRY_PROFILE=production \ forge script script/MainDeploy.s.sol:VerifyDeployment \ - --rpc-url "$HOME_RPC" + --rpc-url "$HOME_RPC" \ + --skip test echo "" success "Verification complete." @@ -522,9 +525,14 @@ print_summary() { local sat_file="$SCRIPT_DIR/satellite-state-${domain}.json" if [ -f "$sat_file" ]; then echo "Satellite: $name (domain $domain)" - echo " PoaManagerSatellite: $(json_get "$sat_file" "satellite")" - echo " PoaManager: $(json_get "$sat_file" "poaManager")" - echo " ImplRegistry: $(json_get "$sat_file" "implRegistry")" + echo " PoaManagerSatellite: $(json_get "$sat_file" "satellite")" + echo " PoaManager: $(json_get "$sat_file" "poaManager")" + echo " ImplRegistry: $(json_get "$sat_file" "implRegistry")" + echo " OrgRegistry: $(json_get "$sat_file" "orgRegistry")" + echo " OrgDeployer: $(json_get "$sat_file" "orgDeployer")" + echo " PaymasterHub: $(json_get "$sat_file" "paymasterHub")" + echo " GlobalAccountRegistry: $(json_get "$sat_file" "globalAccountRegistry")" + echo " UniversalPasskeyFactory: $(json_get "$sat_file" "universalPasskeyFactory")" echo "" else warn "Satellite $name: state file not found ($sat_file)" diff --git a/script/e2e/InfraConfigE2E.s.sol b/script/e2e/InfraConfigE2E.s.sol new file mode 100644 index 0000000..b47947b --- /dev/null +++ b/script/e2e/InfraConfigE2E.s.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {ImplementationRegistry} from "../../src/ImplementationRegistry.sol"; + +/** + * @title TriggerInfraConfig + * @notice Tests the real admin call chain by calling registerImplementation() + * on the ImplementationRegistry (owned by PoaManager) via Hub.adminCall. + * + * Chain of custody: + * Hub.adminCall(implRegistry, registerImplementation(...)) + * → PoaManager.adminCall(implRegistry, data) + * → implRegistry.registerImplementation(...) [checks onlyOwner, owner == PM] + * + * This is the exact same path that real infra config changes follow + * (e.g., PaymasterHub config, OrgDeployer wiring, etc.) + * + * Required env vars: + * PRIVATE_KEY, HUB, IMPL_REGISTRY + */ +contract TriggerInfraConfig is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address hubAddr = vm.envAddress("HUB"); + address regAddr = vm.envAddress("IMPL_REGISTRY"); + + console.log("\n=== Trigger Infra Config via Admin Call ==="); + console.log("Hub:", hubAddr); + console.log("ImplementationRegistry:", regAddr); + + // Use the deployer address itself as the "implementation" — it's just an address + // that exists on-chain. The point is to prove the admin call reaches the registry. + address fakeImpl = vm.addr(deployerKey); + + bytes memory data = abi.encodeWithSignature( + "registerImplementation(string,string,address,bool)", "E2EConfigTest", "v1", fakeImpl, true + ); + + console.log("Registering type 'E2EConfigTest' v1 ->", fakeImpl); + + vm.startBroadcast(deployerKey); + PoaManagerHub(payable(hubAddr)).adminCall(regAddr, data); + vm.stopBroadcast(); + + console.log("Admin call succeeded - PoaManager is confirmed as msg.sender"); + console.log("(ImplementationRegistry.registerImplementation is onlyOwner, owner == PoaManager)"); + } +} + +/** + * @title TriggerInfraConfigSatellite + * @notice Same test on the satellite chain. Calls registerImplementation() + * on the satellite's ImplementationRegistry via Satellite.adminCall. + * + * Chain of custody: + * Satellite.adminCall(implRegistry, registerImplementation(...)) + * → PoaManager.adminCall(implRegistry, data) + * → implRegistry.registerImplementation(...) [checks onlyOwner, owner == PM] + * + * Required env vars: + * PRIVATE_KEY, SATELLITE, IMPL_REGISTRY + */ +contract TriggerInfraConfigSatellite is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address satAddr = vm.envAddress("SATELLITE"); + address regAddr = vm.envAddress("IMPL_REGISTRY"); + + console.log("\n=== Trigger Infra Config on Satellite ==="); + console.log("Satellite:", satAddr); + console.log("ImplementationRegistry:", regAddr); + + address fakeImpl = vm.addr(deployerKey); + + bytes memory data = abi.encodeWithSignature( + "registerImplementation(string,string,address,bool)", "E2EConfigTest", "v1", fakeImpl, true + ); + + console.log("Registering type 'E2EConfigTest' v1 ->", fakeImpl); + + vm.startBroadcast(deployerKey); + // Satellite.adminCall → PM.adminCall → implRegistry.registerImplementation + (bool success,) = satAddr.call(abi.encodeWithSignature("adminCall(address,bytes)", regAddr, data)); + require(success, "Satellite adminCall failed"); + vm.stopBroadcast(); + + console.log("Admin call succeeded on satellite"); + } +} + +/** + * @title VerifyInfraConfig + * @notice Verifies that the ImplementationRegistry now has the "E2EConfigTest" type. + * This proves the full admin call chain worked: the PoaManager was msg.sender + * and the onlyOwner check passed. + * + * Required env vars: + * IMPL_REGISTRY + */ +contract VerifyInfraConfig is Script { + function run() public view { + address regAddr = vm.envAddress("IMPL_REGISTRY"); + + ImplementationRegistry reg = ImplementationRegistry(regAddr); + + console.log("=== Verify Infra Config ==="); + console.log("ImplementationRegistry:", regAddr); + + address impl = reg.getLatestImplementation("E2EConfigTest"); + console.log("E2EConfigTest latest impl:", impl); + + if (impl != address(0)) { + console.log("PASS: ImplementationRegistry configured via admin call"); + } else { + revert("FAIL: E2EConfigTest not found in registry"); + } + } +} diff --git a/script/e2e/InfraUpgradeE2E.s.sol b/script/e2e/InfraUpgradeE2E.s.sol new file mode 100644 index 0000000..9d2fdf2 --- /dev/null +++ b/script/e2e/InfraUpgradeE2E.s.sol @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; +import {PoaManagerSatellite} from "../../src/crosschain/PoaManagerSatellite.sol"; +import {PoaManager} from "../../src/PoaManager.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; + +/// @dev Dummy implementation used to register infra types in the E2E test. +/// The cross-chain upgrade mechanism is identical regardless of contract content. +contract DummyInfraV1 { + function version() external pure returns (string memory) { + return "v1"; + } +} + +/// @dev Dummy v2 implementation for infra upgrade testing. +contract DummyInfraV2 { + function version() external pure returns (string memory) { + return "v2"; + } +} + +/** + * @title RegisterInfraTypesHome + * @notice Registers OrgDeployer, PaymasterHub, and UniversalAccountRegistry + * as contract types on the home chain PoaManager (via Hub). + * Deploys v1 implementations via DeterministicDeployer. + * + * Required env vars: + * PRIVATE_KEY, HUB, DETERMINISTIC_DEPLOYER + */ +contract RegisterInfraTypesHome is Script { + string[3] internal INFRA_TYPES = ["OrgDeployer", "PaymasterHub", "UniversalAccountRegistry"]; + + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address hubAddr = vm.envAddress("HUB"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + PoaManagerHub hub = PoaManagerHub(payable(hubAddr)); + + console.log("\n=== Register Infra Types on Home Chain ==="); + + vm.startBroadcast(deployerKey); + + for (uint256 i = 0; i < INFRA_TYPES.length; i++) { + bytes32 salt = dd.computeSalt(INFRA_TYPES[i], "v1"); + address predicted = dd.computeAddress(salt); + if (predicted.code.length == 0) { + dd.deploy(salt, type(DummyInfraV1).creationCode); + } + hub.addContractType(INFRA_TYPES[i], predicted); + console.log(" Registered:", INFRA_TYPES[i], "->", predicted); + } + + vm.stopBroadcast(); + console.log("Home chain infra types registered."); + } +} + +/** + * @title RegisterInfraTypesSatellite + * @notice Registers infra types on the satellite PoaManager (via Satellite). + * Uses the same DD-predicted addresses (same on both chains). + * + * Required env vars: + * PRIVATE_KEY, SATELLITE, DETERMINISTIC_DEPLOYER + */ +contract RegisterInfraTypesSatellite is Script { + string[3] internal INFRA_TYPES = ["OrgDeployer", "PaymasterHub", "UniversalAccountRegistry"]; + + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address satAddr = vm.envAddress("SATELLITE"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + PoaManagerSatellite satellite = PoaManagerSatellite(payable(satAddr)); + + console.log("\n=== Register Infra Types on Satellite ==="); + + vm.startBroadcast(deployerKey); + + for (uint256 i = 0; i < INFRA_TYPES.length; i++) { + bytes32 salt = dd.computeSalt(INFRA_TYPES[i], "v1"); + address predicted = dd.computeAddress(salt); + if (predicted.code.length == 0) { + dd.deploy(salt, type(DummyInfraV1).creationCode); + } + satellite.addContractType(INFRA_TYPES[i], predicted); + console.log(" Registered:", INFRA_TYPES[i], "->", predicted); + } + + vm.stopBroadcast(); + console.log("Satellite infra types registered."); + } +} + +/** + * @title DeployInfraV2 + * @notice Deploys v2 implementations for all 3 infra types via DD on the current chain. + * Run on BOTH home and satellite chains before triggering upgrades. + * + * Required env vars: + * PRIVATE_KEY, DETERMINISTIC_DEPLOYER + */ +contract DeployInfraV2 is Script { + string[3] internal INFRA_TYPES = ["OrgDeployer", "PaymasterHub", "UniversalAccountRegistry"]; + + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + + console.log("\n=== Deploying Infra V2 Implementations ==="); + + vm.startBroadcast(deployerKey); + + for (uint256 i = 0; i < INFRA_TYPES.length; i++) { + bytes32 salt = dd.computeSalt(INFRA_TYPES[i], "v2"); + address predicted = dd.computeAddress(salt); + if (predicted.code.length > 0) { + console.log(" Already deployed:", INFRA_TYPES[i], "v2 ->", predicted); + } else { + dd.deploy(salt, type(DummyInfraV2).creationCode); + console.log(" Deployed:", INFRA_TYPES[i], "v2 ->", predicted); + } + } + + vm.stopBroadcast(); + } +} + +/** + * @title TriggerCrossChainInfraUpgrade + * @notice Triggers cross-chain beacon upgrades for all 3 infra types from the Hub. + * + * Required env vars: + * PRIVATE_KEY, HUB, DETERMINISTIC_DEPLOYER + */ +contract TriggerCrossChainInfraUpgrade is Script { + string[3] internal INFRA_TYPES = ["OrgDeployer", "PaymasterHub", "UniversalAccountRegistry"]; + + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address hubAddr = vm.envAddress("HUB"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + PoaManagerHub hub = PoaManagerHub(payable(hubAddr)); + + console.log("\n=== Triggering Cross-Chain Infra Upgrades ==="); + + // 0.001 ETH per type per satellite (generous buffer) + uint256 feePerType = 0.001 ether; + + vm.startBroadcast(deployerKey); + + for (uint256 i = 0; i < INFRA_TYPES.length; i++) { + bytes32 salt = dd.computeSalt(INFRA_TYPES[i], "v2"); + address newImpl = dd.computeAddress(salt); + require(newImpl.code.length > 0, "V2 impl not deployed"); + + hub.upgradeBeaconCrossChain{value: feePerType}(INFRA_TYPES[i], newImpl, "v2"); + console.log(" Upgrade dispatched:", INFRA_TYPES[i], "->", newImpl); + } + + vm.stopBroadcast(); + console.log("All infra upgrades dispatched. Hyperlane will relay."); + } +} + +/** + * @title VerifyInfraUpgrade + * @notice Read-only verification that infra type beacons point to v2 implementations. + * Reverts if any type is not yet upgraded (for shell script polling). + * + * Required env vars: + * POAMANAGER, DETERMINISTIC_DEPLOYER + */ +contract VerifyInfraUpgrade is Script { + string[3] internal INFRA_TYPES = ["OrgDeployer", "PaymasterHub", "UniversalAccountRegistry"]; + + function run() public view { + address pmAddr = vm.envAddress("POAMANAGER"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + PoaManager pm = PoaManager(pmAddr); + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + + console.log("=== Verify Infra Upgrades ==="); + + uint256 upgraded; + for (uint256 i = 0; i < INFRA_TYPES.length; i++) { + bytes32 typeId = keccak256(bytes(INFRA_TYPES[i])); + address currentImpl = pm.getCurrentImplementationById(typeId); + + bytes32 salt = dd.computeSalt(INFRA_TYPES[i], "v2"); + address expectedV2 = dd.computeAddress(salt); + + console.log(INFRA_TYPES[i]); + console.log(" Current:", currentImpl); + console.log(" Expected:", expectedV2); + + if (currentImpl == expectedV2) { + console.log(" OK"); + upgraded++; + } else { + console.log(" PENDING"); + } + } + + if (upgraded == 3) { + console.log("PASS: All 3 infra types upgraded to v2"); + } else { + revert("PENDING: Not all infra types upgraded yet"); + } + } +} diff --git a/script/e2e/TriggerAdminCall.s.sol b/script/e2e/TriggerAdminCall.s.sol new file mode 100644 index 0000000..7faa511 --- /dev/null +++ b/script/e2e/TriggerAdminCall.s.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; +import {PoaManagerHub} from "../../src/crosschain/PoaManagerHub.sol"; + +/// @dev Minimal contract deployed at the same address on every chain via DD. +/// Used by the E2E test to verify cross-chain admin calls. +contract AdminCallTarget { + uint256 public value; + uint256 public callCount; + + function setValue(uint256 _val) external { + value = _val; + callCount++; + } +} + +/** + * @title DeployAdminCallTarget + * @notice Deploys AdminCallTarget via DeterministicDeployer on the current chain. + * Run on BOTH home and satellite chains before triggering the admin call. + * + * Required env vars: + * PRIVATE_KEY, DETERMINISTIC_DEPLOYER + */ +contract DeployAdminCallTarget is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + bytes32 salt = dd.computeSalt("AdminCallTarget", "v1"); + address predicted = dd.computeAddress(salt); + + console.log("\n=== Deploying AdminCallTarget ==="); + console.log("Predicted address:", predicted); + + vm.startBroadcast(deployerKey); + + if (predicted.code.length > 0) { + console.log("Already deployed at:", predicted); + } else { + address deployed = dd.deploy(salt, type(AdminCallTarget).creationCode); + console.log("Deployed at:", deployed); + } + + vm.stopBroadcast(); + } +} + +/** + * @title TriggerAdminCall + * @notice Triggers a cross-chain admin call from the Hub. + * Calls AdminCallTarget.setValue(42) on home chain and all satellites. + * + * Required env vars: + * PRIVATE_KEY, HUB, DETERMINISTIC_DEPLOYER + */ +contract TriggerAdminCall is Script { + function run() public { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address hubAddr = vm.envAddress("HUB"); + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + bytes32 salt = dd.computeSalt("AdminCallTarget", "v1"); + address target = dd.computeAddress(salt); + + console.log("\n=== Triggering Cross-Chain Admin Call ==="); + console.log("Hub:", hubAddr); + console.log("Target:", target); + + require(target.code.length > 0, "AdminCallTarget not deployed on this chain"); + + bytes memory data = abi.encodeWithSignature("setValue(uint256)", 42); + + // Send ETH to cover Hyperlane protocol fees (0.001 ETH per satellite, generous buffer) + uint256 fee = 0.001 ether; + console.log("Sending fee:", fee); + + vm.startBroadcast(deployerKey); + PoaManagerHub(payable(hubAddr)).adminCallCrossChain{value: fee}(target, data); + vm.stopBroadcast(); + + console.log("Admin call dispatched. Hyperlane will relay to satellites."); + } +} diff --git a/script/e2e/VerifyAdminCall.s.sol b/script/e2e/VerifyAdminCall.s.sol new file mode 100644 index 0000000..b3fdd71 --- /dev/null +++ b/script/e2e/VerifyAdminCall.s.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "forge-std/console.sol"; +import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; + +interface IAdminCallTarget { + function value() external view returns (uint256); + function callCount() external view returns (uint256); +} + +/** + * @title VerifyAdminCall + * @notice Read-only verification that the AdminCallTarget was updated by the cross-chain + * admin call. Reverts if the value hasn't been set yet (shell script checks exit code + * for polling). + * + * Required env vars: + * DETERMINISTIC_DEPLOYER + */ +contract VerifyAdminCall is Script { + function run() public view { + address ddAddr = vm.envAddress("DETERMINISTIC_DEPLOYER"); + + DeterministicDeployer dd = DeterministicDeployer(ddAddr); + bytes32 salt = dd.computeSalt("AdminCallTarget", "v1"); + address target = dd.computeAddress(salt); + + IAdminCallTarget t = IAdminCallTarget(target); + + console.log("=== Verify Admin Call ==="); + console.log("Target:", target); + console.log("Value:", t.value()); + console.log("Call count:", t.callCount()); + + if (t.value() == 42 && t.callCount() == 1) { + console.log("PASS: Admin call applied (value == 42, callCount == 1)"); + } else if (t.value() == 42 && t.callCount() > 1) { + revert("FAIL: Admin call applied but callCount > 1 (possible replay)"); + } else { + revert("PENDING: Admin call not yet applied"); + } + } +} diff --git a/script/helpers/DeployHelper.s.sol b/script/helpers/DeployHelper.s.sol index 8f6b181..6a794ca 100644 --- a/script/helpers/DeployHelper.s.sol +++ b/script/helpers/DeployHelper.s.sol @@ -18,6 +18,9 @@ import {EligibilityModule} from "../../src/EligibilityModule.sol"; import {ToggleModule} from "../../src/ToggleModule.sol"; import {PasskeyAccount} from "../../src/PasskeyAccount.sol"; import {PasskeyAccountFactory} from "../../src/PasskeyAccountFactory.sol"; +import {OrgRegistry} from "../../src/OrgRegistry.sol"; +import {OrgDeployer} from "../../src/OrgDeployer.sol"; +import {PaymasterHub} from "../../src/PaymasterHub.sol"; import {PoaManager} from "../../src/PoaManager.sol"; import {DeterministicDeployer} from "../../src/crosschain/DeterministicDeployer.sol"; @@ -37,6 +40,11 @@ abstract contract DeployHelper is Script { bytes creationCode; } + 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; + /// @notice Canonical list of the 13 application contract types. /// Infrastructure types (ImplementationRegistry, OrgRegistry, /// OrgDeployer, PaymasterHub) are handled separately because they @@ -58,6 +66,15 @@ abstract contract DeployHelper is Script { types[12] = ContractType("PasskeyAccountFactory", type(PasskeyAccountFactory).creationCode); } + /// @notice Infrastructure contract types that need beacon registration for cross-chain upgrades. + /// Handled separately from application types because they require special initialization. + function _infraContractTypes() internal pure returns (ContractType[] memory types) { + types = new ContractType[](3); + types[0] = ContractType("OrgRegistry", type(OrgRegistry).creationCode); + types[1] = ContractType("OrgDeployer", type(OrgDeployer).creationCode); + types[2] = ContractType("PaymasterHub", type(PaymasterHub).creationCode); + } + /// @notice Deploy all application types directly and register on PoaManager (home chain). function _deployAndRegisterTypes(PoaManager pm) internal { ContractType[] memory types = _contractTypes(); @@ -88,4 +105,20 @@ abstract contract DeployHelper is Script { pm.addContractType(types[i].name, predicted); } } + + /// @notice Deploy infrastructure types via DeterministicDeployer and register on PoaManager (satellite). + function _deployAndRegisterInfraTypesDD(PoaManager pm, DeterministicDeployer dd) internal { + ContractType[] memory types = _infraContractTypes(); + for (uint256 i = 0; i < types.length; i++) { + bytes32 salt = dd.computeSalt(types[i].name, "v1"); + address predicted = dd.computeAddress(salt); + if (predicted.code.length == 0) { + dd.deploy(salt, types[i].creationCode); + console.log(" Deployed infra:", types[i].name); + } else { + console.log(" Already deployed infra:", types[i].name); + } + pm.addContractType(types[i].name, predicted); + } + } } diff --git a/script/testnet-e2e.sh b/script/testnet-e2e.sh index 7729134..cacb5cb 100755 --- a/script/testnet-e2e.sh +++ b/script/testnet-e2e.sh @@ -2,19 +2,22 @@ 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 1: Cross-chain beacon upgrade (MSG_UPGRADE_BEACON) +# Test 2: Cross-chain admin call (MSG_ADMIN_CALL) +# # 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 --skip-deploy # Skip infrastructure, test upgrade only +# ./script/testnet-e2e.sh # Full deploy + all tests +# ./script/testnet-e2e.sh --skip-deploy # Skip infrastructure, run tests only ############################################################################# SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -164,6 +167,30 @@ forge script script/e2e/RegisterSatellite.s.sol:RegisterSatellite \ echo " Satellite registered." echo "" +########################################################################### +# STEP 4b: Register infra types on BOTH chains (for infra upgrade test) +########################################################################### +echo ">>> STEP 4b: Register infra types (OrgDeployer, PaymasterHub, UniversalAccountRegistry)..." + +echo " Home chain (via Hub)..." +HUB=$HUB_ADDR \ +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/InfraUpgradeE2E.s.sol:RegisterInfraTypesHome \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +echo "" + +echo " Satellite (via Satellite)..." +SATELLITE=$SAT_ADDR \ +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/InfraUpgradeE2E.s.sol:RegisterInfraTypesSatellite \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow +echo " Infra types registered on both chains." +echo "" + else # --skip-deploy: Read state from existing JSON echo ">>> Skipping deployment, reading existing state..." @@ -182,6 +209,14 @@ else echo "" fi +########################################################################### +# TEST 1: Cross-Chain Beacon Upgrade (MSG_UPGRADE_BEACON) +########################################################################### +echo "============================================================" +echo " TEST 1: Cross-Chain Beacon Upgrade" +echo "============================================================" +echo "" + ########################################################################### # STEP 5: Deploy HybridVoting v2 on BOTH chains ########################################################################### @@ -202,21 +237,28 @@ forge script script/e2e/DeployV2AndUpgrade.s.sol:DeployV2Impl \ echo "" ########################################################################### -# STEP 6: Trigger cross-chain upgrade from Hub +# STEP 6: Trigger cross-chain upgrade from Hub (skip if already applied) ########################################################################### echo ">>> STEP 6: Trigger cross-chain upgrade..." -HUB=$HUB_ADDR \ -DETERMINISTIC_DEPLOYER=$DD_ADDR \ -forge script script/e2e/DeployV2AndUpgrade.s.sol:TriggerCrossChainUpgrade \ - --rpc-url $HOME_RPC \ - --broadcast \ - --slow +if POAMANAGER=$HOME_PM \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/VerifyUpgrade.s.sol:VerifyUpgrade \ + --rpc-url $HOME_RPC 2>&1 | grep -q "PASS"; then + echo " Already upgraded — skipping trigger." +else + HUB=$HUB_ADDR \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/DeployV2AndUpgrade.s.sol:TriggerCrossChainUpgrade \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +fi echo "" ########################################################################### # STEP 7: Verify home chain immediately ########################################################################### -echo ">>> STEP 7: Verify home chain upgrade (should be instant)..." +echo ">>> STEP 7: Verify home chain upgrade..." POAMANAGER=$HOME_PM \ DETERMINISTIC_DEPLOYER=$DD_ADDR \ forge script script/e2e/VerifyUpgrade.s.sol:VerifyUpgrade \ @@ -233,6 +275,7 @@ echo "" MAX_ATTEMPTS=20 ATTEMPT=0 +UPGRADE_OK=false while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do ATTEMPT=$((ATTEMPT + 1)) echo " Attempt $ATTEMPT/$MAX_ATTEMPTS..." @@ -242,13 +285,12 @@ while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do 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 " >> Cross-chain upgrade verified on both chains!" + echo " Home (Sepolia): HybridVoting beacon -> V2" + echo " Satellite (Base Sepolia): HybridVoting beacon -> V2" echo "" - echo " Home (Sepolia): HybridVoting beacon -> V2" - echo " Satellite (Base Sepolia): HybridVoting beacon -> V2" - echo "============================================================" - exit 0 + UPGRADE_OK=true + break fi if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then @@ -257,17 +299,343 @@ while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do fi done +if [ "$UPGRADE_OK" != "true" ]; then + echo "" + echo " >> WARNING: Satellite upgrade not verified after 10 minutes." + echo " Hyperlane relay may be slow. Continuing to admin call test..." + echo "" +fi + +########################################################################### +# TEST 2: Cross-Chain Admin Call (MSG_ADMIN_CALL) +########################################################################### +echo "============================================================" +echo " TEST 2: Cross-Chain Admin Call" +echo "============================================================" +echo "" + +########################################################################### +# STEP 9: Deploy AdminCallTarget on BOTH chains +########################################################################### +echo ">>> STEP 9a: Deploy AdminCallTarget on Sepolia..." +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/TriggerAdminCall.s.sol:DeployAdminCallTarget \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +echo "" + +echo ">>> STEP 9b: Deploy AdminCallTarget on Base Sepolia..." +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/TriggerAdminCall.s.sol:DeployAdminCallTarget \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow +echo "" + +########################################################################### +# STEP 10: Trigger cross-chain admin call from Hub (skip if already applied) +########################################################################### +echo ">>> STEP 10: Trigger cross-chain admin call (setValue(42))..." +if DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/VerifyAdminCall.s.sol:VerifyAdminCall \ + --rpc-url $HOME_RPC 2>&1 | grep -q "PASS"; then + echo " Already applied — skipping trigger." +else + HUB=$HUB_ADDR \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/TriggerAdminCall.s.sol:TriggerAdminCall \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +fi +echo "" + +########################################################################### +# STEP 11: Verify home chain admin call immediately +########################################################################### +echo ">>> STEP 11: Verify home chain admin call (should be instant)..." +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/VerifyAdminCall.s.sol:VerifyAdminCall \ + --rpc-url $HOME_RPC +echo " Home chain admin call verified." +echo "" + +########################################################################### +# STEP 12: Poll satellite chain for admin call (Hyperlane relay ~2-5 min) +########################################################################### +echo ">>> STEP 12: Waiting for Hyperlane relay of admin call to Base Sepolia..." +echo " Polling every 30s, max 10 minutes." +echo "" + +MAX_ATTEMPTS=20 +ATTEMPT=0 +ADMIN_CALL_OK=false +while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo " Attempt $ATTEMPT/$MAX_ATTEMPTS..." + + if DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/VerifyAdminCall.s.sol:VerifyAdminCall \ + --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then + echo "" + echo " >> Cross-chain admin call verified on both chains!" + echo " Home (Sepolia): AdminCallTarget.value() == 42" + echo " Satellite (Base Sepolia): AdminCallTarget.value() == 42" + echo "" + ADMIN_CALL_OK=true + break + fi + + if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then + echo " Not yet applied. Sleeping 30s..." + sleep 30 + fi +done + +if [ "$ADMIN_CALL_OK" != "true" ]; then + echo "" + echo " >> WARNING: Satellite admin call not verified after 10 minutes." + echo " This may be normal -- Hyperlane relay can take longer on testnets." + echo " Check the Hyperlane Explorer: https://explorer.hyperlane.xyz" + echo "" + echo " Re-run verification manually:" + echo " DETERMINISTIC_DEPLOYER=$DD_ADDR \\" + echo " forge script script/e2e/VerifyAdminCall.s.sol:VerifyAdminCall \\" + echo " --rpc-url base-sepolia" + echo "" +fi + +########################################################################### +# TEST 3: Cross-Chain Infra Beacon Upgrades +# (OrgDeployer, PaymasterHub, UniversalAccountRegistry) +########################################################################### +echo "============================================================" +echo " TEST 3: Cross-Chain Infra Beacon Upgrades" +echo "============================================================" +echo "" + +########################################################################### +# STEP 13: Deploy infra v2 implementations on BOTH chains +########################################################################### +echo ">>> STEP 13a: Deploy infra v2 implementations on Sepolia..." +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/InfraUpgradeE2E.s.sol:DeployInfraV2 \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +echo "" + +echo ">>> STEP 13b: Deploy infra v2 implementations on Base Sepolia..." +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/InfraUpgradeE2E.s.sol:DeployInfraV2 \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow +echo "" + +########################################################################### +# STEP 14: Trigger cross-chain infra upgrades from Hub (skip if already applied) +########################################################################### +echo ">>> STEP 14: Trigger cross-chain infra upgrades..." +if POAMANAGER=$HOME_PM \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/InfraUpgradeE2E.s.sol:VerifyInfraUpgrade \ + --rpc-url $HOME_RPC 2>&1 | grep -q "PASS"; then + echo " Already upgraded — skipping trigger." +else + HUB=$HUB_ADDR \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/InfraUpgradeE2E.s.sol:TriggerCrossChainInfraUpgrade \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +fi +echo "" + +########################################################################### +# STEP 15: Verify home chain infra upgrades immediately +########################################################################### +echo ">>> STEP 15: Verify home chain infra upgrades (should be instant)..." +POAMANAGER=$HOME_PM \ +DETERMINISTIC_DEPLOYER=$DD_ADDR \ +forge script script/e2e/InfraUpgradeE2E.s.sol:VerifyInfraUpgrade \ + --rpc-url $HOME_RPC +echo " Home chain infra upgrades verified." +echo "" + +########################################################################### +# STEP 16: Poll satellite chain for infra upgrades +########################################################################### +echo ">>> STEP 16: Waiting for Hyperlane relay of infra upgrades to Base Sepolia..." +echo " Polling every 30s, max 10 minutes." echo "" + +MAX_ATTEMPTS=20 +ATTEMPT=0 +INFRA_UPGRADE_OK=false +while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo " Attempt $ATTEMPT/$MAX_ATTEMPTS..." + + if POAMANAGER=$SAT_PM \ + DETERMINISTIC_DEPLOYER=$DD_ADDR \ + forge script script/e2e/InfraUpgradeE2E.s.sol:VerifyInfraUpgrade \ + --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then + echo "" + echo " >> Cross-chain infra upgrades verified on both chains!" + echo " OrgDeployer, PaymasterHub, UniversalAccountRegistry -> v2" + echo "" + INFRA_UPGRADE_OK=true + break + fi + + if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then + echo " Not yet upgraded. Sleeping 30s..." + sleep 30 + fi +done + +if [ "$INFRA_UPGRADE_OK" != "true" ]; then + echo "" + echo " >> WARNING: Satellite infra upgrades not verified after 10 minutes." + echo " Check the Hyperlane Explorer: https://explorer.hyperlane.xyz" + echo "" +fi + +########################################################################### +# TEST 4: Admin Call → Real Infra Config +# Calls registerImplementation() on the ImplementationRegistry (onlyOwner, +# where owner == PoaManager). This is the exact same access-control path +# that real infra config changes use (PaymasterHub, OrgDeployer, etc.) +########################################################################### +echo "============================================================" +echo " TEST 4: Real Infra Config (admin call → ImplementationRegistry)" 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" + +INFRA_CONFIG_OK=false +HOME_REG=$(json_get "$STATE_FILE" "homeChain.implRegistry") +SAT_REG=$(json_get "$STATE_FILE" "satellite.implRegistry") +echo " Home ImplementationRegistry: $HOME_REG" +echo " Satellite ImplementationRegistry: $SAT_REG" +echo "" + +########################################################################### +# STEP 17: Config home chain ImplementationRegistry via Hub.adminCall +# (skip if already applied — VersionExists revert on re-run) +########################################################################### +echo ">>> STEP 17: Config home ImplementationRegistry via Hub.adminCall..." +echo " Hub.adminCall(implRegistry, registerImplementation('E2EConfigTest','v1',addr,true))" +echo " implRegistry checks: onlyOwner (owner == PoaManager)" +if IMPL_REGISTRY=$HOME_REG \ + forge script script/e2e/InfraConfigE2E.s.sol:VerifyInfraConfig \ + --rpc-url $HOME_RPC 2>&1 | grep -q "PASS"; then + echo " Already configured — skipping trigger." +else + HUB=$HUB_ADDR \ + IMPL_REGISTRY=$HOME_REG \ + forge script script/e2e/InfraConfigE2E.s.sol:TriggerInfraConfig \ + --rpc-url $HOME_RPC \ + --broadcast \ + --slow +fi +echo "" + +########################################################################### +# STEP 18: Verify home chain config +########################################################################### +echo ">>> STEP 18: Verify home chain ImplementationRegistry config..." +IMPL_REGISTRY=$HOME_REG \ +forge script script/e2e/InfraConfigE2E.s.sol:VerifyInfraConfig \ + --rpc-url $HOME_RPC +echo "" + +########################################################################### +# STEP 19: Config satellite ImplementationRegistry via Satellite.adminCall +# (skip if already applied — VersionExists revert on re-run) +########################################################################### +echo ">>> STEP 19: Config satellite ImplementationRegistry via Satellite.adminCall..." +echo " Satellite.adminCall(implRegistry, registerImplementation('E2EConfigTest','v1',addr,true))" +echo " implRegistry checks: onlyOwner (owner == PoaManager)" +if IMPL_REGISTRY=$SAT_REG \ + forge script script/e2e/InfraConfigE2E.s.sol:VerifyInfraConfig \ + --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then + echo " Already configured — skipping trigger." +else + SATELLITE=$SAT_ADDR \ + IMPL_REGISTRY=$SAT_REG \ + forge script script/e2e/InfraConfigE2E.s.sol:TriggerInfraConfigSatellite \ + --rpc-url $SAT_RPC \ + --broadcast \ + --slow +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" + +########################################################################### +# STEP 20: Verify satellite config (retry a few times for RPC consistency) +########################################################################### +echo ">>> STEP 20: Verify satellite ImplementationRegistry config..." +echo " (Retrying up to 5 times for RPC eventual consistency)" + +VERIFY_ATTEMPTS=5 +VERIFY_I=0 +while [ $VERIFY_I -lt $VERIFY_ATTEMPTS ]; do + VERIFY_I=$((VERIFY_I + 1)) + if IMPL_REGISTRY=$SAT_REG \ + forge script script/e2e/InfraConfigE2E.s.sol:VerifyInfraConfig \ + --rpc-url $SAT_RPC 2>&1 | grep -q "PASS"; then + echo "" + echo " >> Infra config verified on both chains!" + echo " Home: Hub.adminCall -> PM -> implRegistry.registerImplementation" + echo " Satellite: Satellite.adminCall -> PM -> implRegistry.registerImplementation" + echo " (ImplementationRegistry.onlyOwner passed - PoaManager is confirmed caller)" + echo "" + INFRA_CONFIG_OK=true + break + fi + if [ $VERIFY_I -lt $VERIFY_ATTEMPTS ]; then + echo " Attempt $VERIFY_I/$VERIFY_ATTEMPTS - not yet visible, retrying in 10s..." + sleep 10 + fi +done + +if [ "$INFRA_CONFIG_OK" != "true" ]; then + echo "" + echo " >> FAIL: Satellite infra config not applied after $VERIFY_ATTEMPTS attempts." + echo "" +fi + +########################################################################### +# SUMMARY +########################################################################### +echo "============================================================" +echo " E2E Test Results" echo "============================================================" -exit 1 +if [ "$UPGRADE_OK" = "true" ]; then + echo " TEST 1 (Beacon Upgrade): PASS" +else + echo " TEST 1 (Beacon Upgrade): TIMEOUT (check manually)" +fi +if [ "$ADMIN_CALL_OK" = "true" ]; then + echo " TEST 2 (Admin Call): PASS" +else + echo " TEST 2 (Admin Call): TIMEOUT (check manually)" +fi +if [ "$INFRA_UPGRADE_OK" = "true" ]; then + echo " TEST 3 (Infra Upgrades): PASS" +else + echo " TEST 3 (Infra Upgrades): TIMEOUT (check manually)" +fi +if [ "$INFRA_CONFIG_OK" = "true" ]; then + echo " TEST 4 (Gated Infra Config): PASS" +else + echo " TEST 4 (Gated Infra Config): FAIL" +fi +echo "============================================================" + +if [ "$UPGRADE_OK" = "true" ] && [ "$ADMIN_CALL_OK" = "true" ] && [ "$INFRA_UPGRADE_OK" = "true" ] && [ "$INFRA_CONFIG_OK" = "true" ]; then + exit 0 +else + exit 1 +fi diff --git a/src/SwitchableBeacon.sol b/src/SwitchableBeacon.sol index afad19f..b4c0729 100644 --- a/src/SwitchableBeacon.sol +++ b/src/SwitchableBeacon.sol @@ -1,28 +1,27 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.20; -interface IBeacon { - function implementation() external view returns (address); -} +import {IBeacon} from "@openzeppelin/contracts/proxy/beacon/IBeacon.sol"; +import {Ownable2Step, Ownable} from "@openzeppelin/contracts/access/Ownable2Step.sol"; /** * @title SwitchableBeacon - * @notice A beacon implementation that can switch between mirroring a global beacon and using a static implementation - * @dev This contract enables organizations to toggle between auto-upgrading (following POA global beacons) - * and pinned mode (using a fixed implementation) without redeploying proxies - * @custom:security-contact security@poa.org + * @notice A beacon that can switch between mirroring a global beacon and using a static implementation. + * @dev Enables organizations to toggle between auto-upgrading (following POA global beacons) + * and pinned mode (using a fixed implementation) without redeploying proxies. + * + * Three sovereignty tiers: + * 1. Mirror mode – org auto-follows the POA global beacon (latest version). + * 2. Static mode – org pins to a specific implementation and votes to upgrade. + * 3. Custom beacon – org calls setMirror() with their own beacon for full custody. */ -contract SwitchableBeacon is IBeacon { +contract SwitchableBeacon is IBeacon, Ownable2Step { enum Mode { Mirror, // Follow the global beacon's implementation Static // Use a pinned implementation } - /// @notice Current owner of this beacon (typically the Executor or UpgradeAdmin) - address public owner; - - /// @notice The pending owner awaiting acceptance of ownership transfer - address public pendingOwner; + /*──────────── Storage ─────────────*/ /// @notice The global POA beacon to mirror when in Mirror mode address public mirrorBeacon; @@ -33,129 +32,52 @@ contract SwitchableBeacon is IBeacon { /// @notice Current operational mode of the beacon Mode public mode; - /// @notice Emitted when ownership is transferred - /// @param previousOwner The address of the previous owner - /// @param newOwner The address of the new owner - event OwnerTransferred(address indexed previousOwner, address indexed newOwner); - - /// @notice Emitted when a pending ownership transfer is started - /// @param pendingOwner The address of the pending new owner - event OwnershipTransferStarted(address indexed pendingOwner); + /*──────────── Events ──────────────*/ - /// @notice Emitted when a pending ownership transfer is cancelled - /// @param cancelledOwner The address of the cancelled pending owner - event OwnershipTransferCancelled(address indexed cancelledOwner); - - /// @notice Emitted when the beacon mode changes - /// @param mode The new mode (Mirror or Static) event ModeChanged(Mode mode); - - /// @notice Emitted when a new mirror beacon is set - /// @param mirrorBeacon The address of the new mirror beacon event MirrorSet(address indexed mirrorBeacon); - - /// @notice Emitted when an implementation is pinned - /// @param implementation The address of the pinned implementation event Pinned(address indexed implementation); - /// @notice Thrown when a non-owner attempts a restricted operation - error NotOwner(); + /*──────────── Errors ──────────────*/ - /// @notice Thrown when a zero address is provided where not allowed - error ZeroAddress(); - - /// @notice Thrown when the implementation address cannot be determined error ImplNotSet(); - - /// @notice Thrown when attempting to set invalid mode transition - error InvalidModeTransition(); - - /// @notice Thrown when an address is not a contract when it should be error NotContract(); + error CannotRenounce(); - /// @notice Thrown when there is no pending ownership transfer to cancel - error NoPendingTransfer(); - - /// @notice Thrown when the caller is not the pending owner - error NotPendingOwner(); - - /// @notice Restricts function access to the owner only - modifier onlyOwner() { - if (msg.sender != owner) revert NotOwner(); - _; - } + /*──────────── Constructor ─────────*/ /** - * @notice Constructs a new SwitchableBeacon - * @param _owner The initial owner of the beacon - * @param _mirrorBeacon The POA global beacon to mirror when in Mirror mode - * @param _staticImpl The static implementation to use when in Static mode (can be address(0) if starting in Mirror mode) - * @param _mode The initial mode of operation + * @notice Constructs a new SwitchableBeacon. + * @param _owner The initial owner of the beacon (typically the Executor). + * @param _mirrorBeacon The POA global beacon to mirror when in Mirror mode. + * @param _staticImpl The static implementation when in Static mode (can be address(0) if starting in Mirror mode). + * @param _mode The initial mode of operation. */ - constructor(address _owner, address _mirrorBeacon, address _staticImpl, Mode _mode) { - if (_owner == address(0)) revert ZeroAddress(); - if (_mirrorBeacon == address(0)) revert ZeroAddress(); - - // Verify mirrorBeacon is a contract - if (_mirrorBeacon.code.length == 0) revert NotContract(); + constructor(address _owner, address _mirrorBeacon, address _staticImpl, Mode _mode) Ownable(_owner) { + if (_mirrorBeacon == address(0) || _mirrorBeacon.code.length == 0) revert NotContract(); - // Static implementation can be zero if starting in Mirror mode if (_mode == Mode.Static) { if (_staticImpl == address(0)) revert ImplNotSet(); - // Verify static implementation is a contract if (_staticImpl.code.length == 0) revert NotContract(); } - owner = _owner; mirrorBeacon = _mirrorBeacon; staticImplementation = _staticImpl; mode = _mode; - emit OwnerTransferred(address(0), _owner); emit ModeChanged(_mode); } - /** - * @notice Initiates a two-step ownership transfer to a new address - * @param newOwner The address of the pending new owner - * @dev Only callable by the current owner. The new owner must call acceptOwnership() to complete the transfer. - */ - function transferOwnership(address newOwner) external onlyOwner { - if (newOwner == address(0)) revert ZeroAddress(); - - pendingOwner = newOwner; - - emit OwnershipTransferStarted(newOwner); - } - - /** - * @notice Completes the ownership transfer - * @dev Only callable by the pending owner - */ - function acceptOwnership() external { - if (msg.sender != pendingOwner) revert NotPendingOwner(); - - address previousOwner = owner; - owner = pendingOwner; - pendingOwner = address(0); + /*══════════════════ Ownership Safety ══════════════════*/ - emit OwnerTransferred(previousOwner, msg.sender); + /// @dev Ownership cannot be renounced — losing it bricks the beacon permanently. + function renounceOwnership() public pure override { + revert CannotRenounce(); } - /// @notice Cancels a pending ownership transfer - /// @dev Only callable by the current owner - function cancelOwnershipTransfer() external onlyOwner { - if (pendingOwner == address(0)) revert NoPendingTransfer(); - address cancelled = pendingOwner; - pendingOwner = address(0); - emit OwnershipTransferCancelled(cancelled); - } + /*══════════════════ IBeacon ══════════════════*/ - /** - * @notice Returns the current implementation address based on the beacon's mode - * @return The address of the implementation contract - * @dev In Mirror mode, queries the mirror beacon. In Static mode, returns the stored implementation. - */ + /// @notice Returns the current implementation address based on the beacon's mode. function implementation() external view override returns (address) { if (mode == Mode.Mirror) { address impl = IBeacon(mirrorBeacon).implementation(); @@ -167,22 +89,15 @@ contract SwitchableBeacon is IBeacon { } } - /** - * @notice Switches to Mirror mode and sets a new mirror beacon - * @param _mirrorBeacon The address of the POA global beacon to mirror - * @dev Only callable by the owner. Enables auto-upgrading by following the global beacon. - */ - function setMirror(address _mirrorBeacon) external onlyOwner { - if (_mirrorBeacon == address(0)) revert ZeroAddress(); + /*══════════════════ Mode Switching ══════════════════*/ - // Verify the beacon is a contract - if (_mirrorBeacon.code.length == 0) revert NotContract(); + /// @notice Switch to Mirror mode, following the given beacon. + /// @param _mirrorBeacon The beacon to mirror (can be the POA global beacon or a custom one). + function setMirror(address _mirrorBeacon) external onlyOwner { + if (_mirrorBeacon == address(0) || _mirrorBeacon.code.length == 0) revert NotContract(); - // Validate that the mirror beacon has a valid implementation address impl = IBeacon(_mirrorBeacon).implementation(); if (impl == address(0)) revert ImplNotSet(); - - // Verify the implementation is a contract if (impl.code.length == 0) revert NotContract(); mirrorBeacon = _mirrorBeacon; @@ -192,16 +107,9 @@ contract SwitchableBeacon is IBeacon { emit ModeChanged(Mode.Mirror); } - /** - * @notice Pins the beacon to a specific implementation address - * @param impl The implementation address to pin - * @dev Only callable by the owner. Switches to Static mode with the specified implementation. - */ + /// @notice Pin the beacon to a specific implementation address. function pin(address impl) public onlyOwner { - if (impl == address(0)) revert ZeroAddress(); - - // Verify the implementation is a contract - if (impl.code.length == 0) revert NotContract(); + if (impl == address(0) || impl.code.length == 0) revert NotContract(); staticImplementation = impl; mode = Mode.Static; @@ -210,31 +118,16 @@ contract SwitchableBeacon is IBeacon { emit ModeChanged(Mode.Static); } - /** - * @notice Pins the beacon to the current implementation of the mirror beacon - * @dev Only callable by the owner. Convenient way to freeze at the current global version. - */ + /// @notice Pin the beacon to the current implementation of the mirror beacon. function pinToCurrent() external onlyOwner { address impl = IBeacon(mirrorBeacon).implementation(); if (impl == address(0)) revert ImplNotSet(); - - // The pin function will validate the implementation is a contract pin(impl); } - /** - * @notice Checks if the beacon is in Mirror mode - * @return True if in Mirror mode, false otherwise - */ - function isMirrorMode() external view returns (bool) { - return mode == Mode.Mirror; - } + /*══════════════════ Views ══════════════════*/ - /** - * @notice Gets the current implementation without reverting - * @return success True if implementation could be determined - * @return impl The implementation address (zero if not determinable) - */ + /// @notice Gets the current implementation without reverting. function tryGetImplementation() external view returns (bool success, address impl) { if (mode == Mode.Mirror) { try IBeacon(mirrorBeacon).implementation() returns (address mirrorImpl) { diff --git a/src/crosschain/PoaManagerHub.sol b/src/crosschain/PoaManagerHub.sol index e7dc426..40f77d5 100644 --- a/src/crosschain/PoaManagerHub.sol +++ b/src/crosschain/PoaManagerHub.sol @@ -22,6 +22,7 @@ contract PoaManagerHub is Ownable(msg.sender) { /// @dev Message type tags for the satellite to distinguish upgrade vs addType uint8 internal constant MSG_UPGRADE_BEACON = 0x01; uint8 internal constant MSG_ADD_CONTRACT_TYPE = 0x02; + uint8 internal constant MSG_ADMIN_CALL = 0x03; /*──────────── Immutables ──────────*/ PoaManager public immutable poaManager; @@ -29,6 +30,7 @@ contract PoaManagerHub is Ownable(msg.sender) { /*──────────── Storage ─────────────*/ SatelliteConfig[] public satellites; + uint256 public activeSatelliteCount; bool public paused; /*──────────── Errors ──────────────*/ @@ -38,14 +40,12 @@ contract PoaManagerHub is Ownable(msg.sender) { error CannotRenounce(); error TransferFailed(); error DuplicateDomain(uint32 domain); + error SatelliteNotActive(); /*──────────── Events ──────────────*/ - event CrossChainUpgradeDispatched( - bytes32 indexed typeId, address newImpl, string version, uint32 indexed domain, bytes32 messageId - ); - event CrossChainAddTypeDispatched( - bytes32 indexed typeId, string typeName, uint32 indexed domain, bytes32 messageId - ); + event CrossChainUpgradeDispatched(bytes32 indexed typeId, address newImpl, string version); + event CrossChainAddTypeDispatched(bytes32 indexed typeId, string typeName, address impl); + event CrossChainAdminCallDispatched(address indexed target, bytes data); event SatelliteRegistered(uint32 indexed domain, address satellite); event SatelliteRemoved(uint32 indexed domain); event PauseSet(bool paused); @@ -68,27 +68,9 @@ contract PoaManagerHub is Ownable(msg.sender) { { if (paused) revert IsPaused(); uint256 preBalance = address(this).balance - msg.value; - - // 1. Upgrade locally (validates impl, updates registry, upgrades beacon) 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; - for (uint256 i; i < len;) { - SatelliteConfig storage sat = satellites[i]; - if (sat.active) { - bytes32 msgId = mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); - emit CrossChainUpgradeDispatched(typeId, newImpl, version, sat.domain, msgId); - } - unchecked { - ++i; - } - } - - _refundExcess(preBalance); + emit CrossChainUpgradeDispatched(keccak256(bytes(typeName)), newImpl, version); + _broadcast(abi.encode(MSG_UPGRADE_BEACON, typeName, newImpl, version), preBalance); } /// @notice Upgrade a beacon on the home chain only (no cross-chain propagation). @@ -109,25 +91,9 @@ contract PoaManagerHub is Ownable(msg.sender) { function addContractTypeCrossChain(string calldata typeName, address impl) external payable onlyOwner { if (paused) revert IsPaused(); uint256 preBalance = address(this).balance - msg.value; - 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; - for (uint256 i; i < len;) { - SatelliteConfig storage sat = satellites[i]; - if (sat.active) { - bytes32 msgId = mailbox.dispatch{value: feePerSatellite}(sat.domain, sat.satellite, payload); - emit CrossChainAddTypeDispatched(typeId, typeName, sat.domain, msgId); - } - unchecked { - ++i; - } - } - - _refundExcess(preBalance); + emit CrossChainAddTypeDispatched(keccak256(bytes(typeName)), typeName, impl); + _broadcast(abi.encode(MSG_ADD_CONTRACT_TYPE, typeName, impl), preBalance); } /*══════════════════ Admin Call Passthrough ══════════════════*/ @@ -139,6 +105,18 @@ contract PoaManagerHub is Ownable(msg.sender) { return poaManager.adminCall(target, data); } + /// @notice Execute an admin call on the home chain AND propagate to all active satellites. + /// @dev Send enough ETH to cover Hyperlane protocol fees for all active satellites. + /// The satellite's PoaManager will call `adminCall(target, data)` on receipt. + /// NOTE: `target` must exist at the same address on all satellite chains. + function adminCallCrossChain(address target, bytes calldata data) external payable onlyOwner { + if (paused) revert IsPaused(); + uint256 preBalance = address(this).balance - msg.value; + poaManager.adminCall(target, data); + emit CrossChainAdminCallDispatched(target, data); + _broadcast(abi.encode(MSG_ADMIN_CALL, target, data), preBalance); + } + /*══════════════════ Registry Passthrough ══════════════════*/ /// @notice Update the ImplementationRegistry on the local PoaManager. @@ -165,12 +143,15 @@ contract PoaManagerHub is Ownable(msg.sender) { satellites.push( SatelliteConfig({domain: domain, satellite: bytes32(uint256(uint160(satellite))), active: true}) ); + ++activeSatelliteCount; emit SatelliteRegistered(domain, satellite); } function removeSatellite(uint256 index) external onlyOwner { + if (!satellites[index].active) revert SatelliteNotActive(); uint32 domain = satellites[index].domain; satellites[index].active = false; + --activeSatelliteCount; emit SatelliteRemoved(domain); } @@ -208,23 +189,22 @@ contract PoaManagerHub is Ownable(msg.sender) { /*══════════════════ Internal ══════════════════*/ - /// @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) { + /// @dev Dispatches payload to every active satellite via Hyperlane. Handles fee split + refund. + function _broadcast(bytes memory payload, uint256 preBalance) internal { + uint256 count = activeSatelliteCount; + if (count == 0) revert NoActiveSatellites(); + uint256 fee = msg.value / count; uint256 len = satellites.length; - uint256 activeCount; for (uint256 i; i < len;) { - if (satellites[i].active) { - unchecked { - ++activeCount; - } + SatelliteConfig storage sat = satellites[i]; + if (sat.active) { + mailbox.dispatch{value: fee}(sat.domain, sat.satellite, payload); } unchecked { ++i; } } - if (activeCount == 0 && msg.value > 0) revert NoActiveSatellites(); - return activeCount > 0 ? msg.value / activeCount : 0; + _refundExcess(preBalance); } /// @dev Refunds only the caller's overpayment (integer division remainder). diff --git a/src/crosschain/PoaManagerSatellite.sol b/src/crosschain/PoaManagerSatellite.sol index ab7538d..10029ed 100644 --- a/src/crosschain/PoaManagerSatellite.sol +++ b/src/crosschain/PoaManagerSatellite.sol @@ -13,6 +13,7 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { /*──────────── Constants ───────────*/ uint8 internal constant MSG_UPGRADE_BEACON = 0x01; uint8 internal constant MSG_ADD_CONTRACT_TYPE = 0x02; + uint8 internal constant MSG_ADMIN_CALL = 0x03; /*──────────── Immutables ──────────*/ PoaManager public immutable poaManager; @@ -35,6 +36,7 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { /*──────────── Events ──────────────*/ event UpgradeReceived(bytes32 indexed typeId, address newImpl, string version, uint32 origin); event ContractTypeReceived(bytes32 indexed typeId, string typeName, address impl, uint32 origin); + event AdminCallReceived(address indexed target, bytes data, uint32 origin); event PauseSet(bool paused); /*──────────── Constructor ─────────*/ @@ -73,6 +75,12 @@ contract PoaManagerSatellite is Ownable(msg.sender), IMessageRecipient { poaManager.addContractType(typeName, impl); emit ContractTypeReceived(keccak256(bytes(typeName)), typeName, impl, _origin); + } else if (msgType == MSG_ADMIN_CALL) { + (, address target, bytes memory data) = abi.decode(_body, (uint8, address, bytes)); + + poaManager.adminCall(target, data); + + emit AdminCallReceived(target, data, _origin); } else { revert UnknownMessageType(); } diff --git a/test/CrossChainUpgradeIntegration.t.sol b/test/CrossChainUpgradeIntegration.t.sol index 8c63d43..e454191 100644 --- a/test/CrossChainUpgradeIntegration.t.sol +++ b/test/CrossChainUpgradeIntegration.t.sol @@ -307,7 +307,68 @@ contract CrossChainUpgradeIntegrationTest is Test { } // ══════════════════════════════════════════════════════════ - // 12. Mixed: one satellite pinned, one mirroring + // 12. Cross-chain admin call propagates to all chains + // ══════════════════════════════════════════════════════════ + + function testCrossChainAdminCallPropagates() public { + // Deploy MockAdminTarget on home chain (owned by homePm) + IntegrationAdminTarget homeTarget = new IntegrationAdminTarget(address(homePm)); + // Deploy MockAdminTarget on satellite 1 (owned by sat1Pm) + IntegrationAdminTarget sat1Target = new IntegrationAdminTarget(address(sat1Pm)); + // Deploy MockAdminTarget on satellite 2 (owned by sat2Pm) + IntegrationAdminTarget sat2Target = new IntegrationAdminTarget(address(sat2Pm)); + + // All targets must be at the same address for cross-chain admin calls. + // Since they won't be, we test with separate calls for realism. + // The hub dispatches the same (target, data) to all satellites. + // For this integration test, we use separate target addresses and + // do two calls: one for home+sat1 target and one for sat2 target. + + // Actually, cross-chain admin call sends the same target address to all chains. + // In production the target must be deployed at the same address on all chains. + // In our synchronous mock, all contracts are on the same EVM, so we can + // test by deploying a single target that accepts calls from any PoaManager. + + // Let's use a permissive target instead: + IntegrationAdminTargetPermissive sharedTarget = new IntegrationAdminTargetPermissive(); + + bytes memory data = abi.encodeWithSignature("setValue(uint256)", 999); + hub.adminCallCrossChain(address(sharedTarget), data); + + // The call is executed 3 times: once locally (via homePm) and once per satellite (via sat1Pm, sat2Pm) + // Since the mock mailbox delivers synchronously, all 3 calls happen + assertEq(sharedTarget.value(), 999, "Target value should be set"); + assertEq(sharedTarget.callCount(), 3, "Should be called 3 times (home + 2 satellites)"); + } + + // ══════════════════════════════════════════════════════════ + // 13. Cross-chain infra beacon upgrade + // ══════════════════════════════════════════════════════════ + + function testCrossChainInfraBeaconUpgrade() public { + // Add an infra-style type "OrgDeployer" on all chains + hub.addContractType("OrgDeployer", address(implV1)); + satellite1.addContractType("OrgDeployer", address(implV1)); + satellite2.addContractType("OrgDeployer", address(implV1)); + + bytes32 typeId = keccak256(bytes("OrgDeployer")); + + // Verify all chains start with V1 + assertEq(homePm.getCurrentImplementationById(typeId), address(implV1), "Home should start at V1"); + assertEq(sat1Pm.getCurrentImplementationById(typeId), address(implV1), "Sat1 should start at V1"); + assertEq(sat2Pm.getCurrentImplementationById(typeId), address(implV1), "Sat2 should start at V1"); + + // Upgrade cross-chain to V2 + hub.upgradeBeaconCrossChain("OrgDeployer", address(implV2), "v2"); + + // Verify all chains upgraded to V2 + assertEq(homePm.getCurrentImplementationById(typeId), address(implV2), "Home should be V2"); + assertEq(sat1Pm.getCurrentImplementationById(typeId), address(implV2), "Sat1 should be V2"); + assertEq(sat2Pm.getCurrentImplementationById(typeId), address(implV2), "Sat2 should be V2"); + } + + // ══════════════════════════════════════════════════════════ + // 14. Mixed: one satellite pinned, one mirroring // ══════════════════════════════════════════════════════════ function testMixedPinnedAndMirroringSatellites() public { @@ -397,3 +458,29 @@ contract IntegrationImplV3 { return 3; } } + +/// @dev Admin target gated behind a specific PoaManager (for single-chain tests) +contract IntegrationAdminTarget { + address public poaManager; + uint256 public value; + + constructor(address _pm) { + poaManager = _pm; + } + + function setValueOnlyPM(uint256 _val) external { + require(msg.sender == poaManager, "not pm"); + value = _val; + } +} + +/// @dev Permissive admin target that accepts calls from any PoaManager (for cross-chain integration tests) +contract IntegrationAdminTargetPermissive { + uint256 public value; + uint256 public callCount; + + function setValue(uint256 _val) external { + value = _val; + callCount++; + } +} diff --git a/test/PoaManagerHub.t.sol b/test/PoaManagerHub.t.sol index ec70872..da766b7 100644 --- a/test/PoaManagerHub.t.sol +++ b/test/PoaManagerHub.t.sol @@ -343,17 +343,14 @@ contract PoaManagerHubTest is Test { } // ══════════════════════════════════════════════════════════ - // 20. Upgrade with no satellites registered dispatches nothing + // 20. Upgrade with no satellites registered reverts // ══════════════════════════════════════════════════════════ - function testUpgradeWithNoSatellitesDispatchesNothing() public { + function testUpgradeWithNoSatellitesReverts() public { hub.addContractType("Widget", address(implV1)); + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); hub.upgradeBeaconCrossChain("Widget", address(implV2), "v2"); - - bytes32 typeId = keccak256(bytes("Widget")); - assertEq(pm.getCurrentImplementationById(typeId), address(implV2), "Local upgrade should still work"); - assertEq(mailbox.dispatchedCount(), 0, "No dispatch with empty satellite list"); } // ══════════════════════════════════════════════════════════ @@ -405,8 +402,8 @@ contract PoaManagerHubTest is Test { bytes32 typeId = keccak256(bytes("Widget")); - vm.expectEmit(true, true, false, false); - emit PoaManagerHub.CrossChainUpgradeDispatched(typeId, address(implV2), "v2", 42, bytes32(0)); + vm.expectEmit(true, false, false, true); + emit PoaManagerHub.CrossChainUpgradeDispatched(typeId, address(implV2), "v2"); hub.upgradeBeaconCrossChain("Widget", address(implV2), "v2"); } @@ -582,27 +579,23 @@ contract PoaManagerHubTest is Test { } // ══════════════════════════════════════════════════════════ - // 36. Upgrade with zero ETH and no active satellites succeeds + // 36. Upgrade with zero ETH and no active satellites reverts // ══════════════════════════════════════════════════════════ - function testUpgradeWithZeroEthAndNoSatellitesSucceeds() public { + function testUpgradeWithZeroEthAndNoSatellitesReverts() public { hub.registerSatellite(42, address(noopSatellite)); hub.removeSatellite(0); hub.addContractType("Widget", address(implV1)); - // No ETH sent, no active satellites — should succeed (local upgrade only) + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); hub.upgradeBeaconCrossChain("Widget", address(implV2), "v2"); - - bytes32 typeId = keccak256(bytes("Widget")); - assertEq(pm.getCurrentImplementationById(typeId), address(implV2)); - assertEq(mailbox.dispatchedCount(), 0); } // ══════════════════════════════════════════════════════════ - // 37. Upgrade after all satellites removed + // 37. Upgrade after all satellites removed reverts // ══════════════════════════════════════════════════════════ - function testUpgradeAfterAllSatellitesRemoved() public { + function testUpgradeAfterAllSatellitesRemovedReverts() public { hub.registerSatellite(10, address(noopSatellite)); NoopRecipient noop2 = new NoopRecipient(); hub.registerSatellite(20, address(noop2)); @@ -610,11 +603,9 @@ contract PoaManagerHubTest is Test { hub.removeSatellite(1); hub.addContractType("Widget", address(implV1)); - hub.upgradeBeaconCrossChain("Widget", address(implV2), "v2"); - bytes32 typeId = keccak256(bytes("Widget")); - assertEq(pm.getCurrentImplementationById(typeId), address(implV2), "Local upgrade should work"); - assertEq(mailbox.dispatchedCount(), 0, "No dispatches when all removed"); + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.upgradeBeaconCrossChain("Widget", address(implV2), "v2"); } // ══════════════════════════════════════════════════════════ @@ -732,6 +723,234 @@ contract PoaManagerHubTest is Test { hub.adminCall(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 99)); } + // ══════════════════════════════════════════════════════════ + // 46. adminCallCrossChain executes local AND dispatches + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainExecutesLocalAndDispatches() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + hub.registerSatellite(42, address(noopSatellite)); + + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 123)); + + // Local target state changed + assertEq(target.value(), 123, "Local target should be updated"); + // One dispatch to the satellite + assertEq(mailbox.dispatchedCount(), 1, "Should dispatch to 1 satellite"); + } + + // ══════════════════════════════════════════════════════════ + // 47. adminCallCrossChain payload encoding + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainPayloadEncoding() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + hub.registerSatellite(42, address(noopSatellite)); + + bytes memory data = abi.encodeWithSignature("setValueOnlyPM(uint256)", 55); + hub.adminCallCrossChain(address(target), data); + + (,, bytes memory body) = mailbox.dispatched(0); + (uint8 msgType, address decodedTarget, bytes memory decodedData) = abi.decode(body, (uint8, address, bytes)); + + assertEq(msgType, 0x03, "Message type should be MSG_ADMIN_CALL"); + assertEq(decodedTarget, address(target), "Target should match"); + assertEq(keccak256(decodedData), keccak256(data), "Data should match"); + } + + // ══════════════════════════════════════════════════════════ + // 48. adminCallCrossChain multiple satellites + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainMultipleSatellites() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + NoopRecipient noop2 = new NoopRecipient(); + NoopRecipient noop3 = new NoopRecipient(); + hub.registerSatellite(10, address(noopSatellite)); + hub.registerSatellite(20, address(noop2)); + hub.registerSatellite(30, address(noop3)); + + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 7)); + + assertEq(mailbox.dispatchedCount(), 3, "All 3 active satellites should receive dispatch"); + } + + // ══════════════════════════════════════════════════════════ + // 49. adminCallCrossChain ETH fee distribution + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainEthFeeDistribution() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + NoopRecipient noop2 = new NoopRecipient(); + NoopRecipient noop3 = new NoopRecipient(); + hub.registerSatellite(10, address(noopSatellite)); + hub.registerSatellite(20, address(noop2)); + hub.registerSatellite(30, address(noop3)); + + uint256 balanceBefore = address(this).balance; + hub.adminCallCrossChain{value: 10}(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + uint256 balanceAfter = address(this).balance; + + // 10 / 3 = 3 per satellite, 1 remainder refunded + assertEq(balanceBefore - balanceAfter, 9, "Should spend 9 wei (3 per satellite), refund 1"); + } + + // ══════════════════════════════════════════════════════════ + // 50. adminCallCrossChain paused reverts + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainPausedReverts() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + hub.registerSatellite(42, address(noopSatellite)); + hub.setPaused(true); + + vm.expectRevert(PoaManagerHub.IsPaused.selector); + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + } + + // ══════════════════════════════════════════════════════════ + // 51. adminCallCrossChain only owner + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainOnlyOwner() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + + vm.prank(nonOwner); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + } + + // ══════════════════════════════════════════════════════════ + // 52. adminCallCrossChain no satellites reverts + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainNoSatellitesReverts() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 42)); + } + + // ══════════════════════════════════════════════════════════ + // 53. adminCallCrossChain emits event + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainEmitsEvent() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + hub.registerSatellite(42, address(noopSatellite)); + + bytes memory data = abi.encodeWithSignature("setValueOnlyPM(uint256)", 99); + + vm.expectEmit(true, false, false, true); + emit PoaManagerHub.CrossChainAdminCallDispatched(address(target), data); + hub.adminCallCrossChain(address(target), data); + } + + // ══════════════════════════════════════════════════════════ + // 54. adminCallCrossChain reverts when inner call reverts + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainRevertsWhenInnerCallReverts() public { + RevertingTarget revertTarget = new RevertingTarget(); + hub.registerSatellite(42, address(noopSatellite)); + + vm.expectRevert("always reverts"); + hub.adminCallCrossChain(address(revertTarget), abi.encodeWithSignature("doRevert()")); + + // No dispatch should have occurred since local call reverted first + assertEq(mailbox.dispatchedCount(), 0, "No dispatches when local call reverts"); + } + + // ══════════════════════════════════════════════════════════ + // 55. adminCallCrossChain with ETH + no active satellites reverts + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainWithEthNoSatellitesReverts() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.adminCallCrossChain{value: 1 ether}(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + } + + // ══════════════════════════════════════════════════════════ + // 56. NoActiveSatellites: addContractTypeCrossChain with 0 satellites + // ══════════════════════════════════════════════════════════ + + function testAddContractTypeCrossChainNoSatellitesReverts() public { + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.addContractTypeCrossChain("Widget", address(implV1)); + } + + // ══════════════════════════════════════════════════════════ + // 57. NoActiveSatellites: addContractTypeCrossChain after all removed + // ══════════════════════════════════════════════════════════ + + function testAddContractTypeCrossChainAllRemovedReverts() public { + hub.registerSatellite(42, address(noopSatellite)); + hub.removeSatellite(0); + + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.addContractTypeCrossChain("Widget", address(implV1)); + } + + // ══════════════════════════════════════════════════════════ + // 58. NoActiveSatellites: adminCallCrossChain after all removed + // ══════════════════════════════════════════════════════════ + + function testAdminCallCrossChainAllRemovedReverts() public { + MockAdminTargetHub target = new MockAdminTargetHub(address(pm)); + hub.registerSatellite(42, address(noopSatellite)); + hub.removeSatellite(0); + + vm.expectRevert(PoaManagerHub.NoActiveSatellites.selector); + hub.adminCallCrossChain(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + } + + // ══════════════════════════════════════════════════════════ + // 59. activeSatelliteCount tracks correctly across register/remove + // ══════════════════════════════════════════════════════════ + + function testActiveSatelliteCountTracking() public { + assertEq(hub.activeSatelliteCount(), 0); + + hub.registerSatellite(10, address(noopSatellite)); + assertEq(hub.activeSatelliteCount(), 1); + + NoopRecipient noop2 = new NoopRecipient(); + hub.registerSatellite(20, address(noop2)); + assertEq(hub.activeSatelliteCount(), 2); + + hub.removeSatellite(0); + assertEq(hub.activeSatelliteCount(), 1); + + hub.removeSatellite(1); + assertEq(hub.activeSatelliteCount(), 0); + + // Re-register + hub.registerSatellite(10, address(noopSatellite)); + assertEq(hub.activeSatelliteCount(), 1); + } + + // ══════════════════════════════════════════════════════════ + // 60. removeSatellite reverts on already-inactive satellite + // ══════════════════════════════════════════════════════════ + + function testRemoveSatelliteRevertsIfAlreadyInactive() public { + hub.registerSatellite(10, address(noopSatellite)); + NoopRecipient noop2 = new NoopRecipient(); + hub.registerSatellite(20, address(noop2)); + + hub.removeSatellite(0); + assertEq(hub.activeSatelliteCount(), 1); + + // Double-remove must revert, not silently corrupt activeSatelliteCount + vm.expectRevert(PoaManagerHub.SatelliteNotActive.selector); + hub.removeSatellite(0); + + // Count unchanged — satellite 1 still active + assertEq(hub.activeSatelliteCount(), 1); + } + // ══════════════════════════════════════════════════════════ // Helper: accept ETH refunds // ══════════════════════════════════════════════════════════ @@ -753,3 +972,10 @@ contract MockAdminTargetHub { value = _val; } } + +/// @dev Mock target that always reverts +contract RevertingTarget { + function doRevert() external pure { + revert("always reverts"); + } +} diff --git a/test/PoaManagerSatellite.t.sol b/test/PoaManagerSatellite.t.sol index b5114ba..7417d9c 100644 --- a/test/PoaManagerSatellite.t.sol +++ b/test/PoaManagerSatellite.t.sol @@ -475,6 +475,105 @@ contract PoaManagerSatelliteTest is Test { vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, nonOwner)); satellite.adminCall(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 77)); } + + /*──────────── Admin Call Cross-Chain Helper ───────────*/ + + function _adminCallPayload(address target, bytes memory data) internal pure returns (bytes memory) { + return abi.encode(uint8(0x03), target, data); + } + + // ══════════════════════════════════════════════════════════ + // 33. handle admin call executes on target + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallExecutesOnTarget() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + + _deliverMessage(_adminCallPayload(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 42))); + + assertEq(target.value(), 42, "Target value should be set via admin call"); + } + + // ══════════════════════════════════════════════════════════ + // 34. handle admin call emits event + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallEmitsEvent() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + bytes memory data = abi.encodeWithSignature("setValueOnlyPM(uint256)", 42); + + vm.expectEmit(true, false, false, true); + emit PoaManagerSatellite.AdminCallReceived(address(target), data, hubDomain); + _deliverMessage(_adminCallPayload(address(target), data)); + } + + // ══════════════════════════════════════════════════════════ + // 35. handle admin call rejects non-mailbox + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallRejectsNonMailbox() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + bytes memory body = _adminCallPayload(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + + vm.prank(address(0xDEAD)); + vm.expectRevert(PoaManagerSatellite.UnauthorizedMailbox.selector); + satellite.handle(hubDomain, bytes32(uint256(uint160(hubAddr))), body); + } + + // ══════════════════════════════════════════════════════════ + // 36. handle admin call rejects wrong origin + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallRejectsWrongOrigin() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + bytes memory body = _adminCallPayload(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + + vm.prank(mailbox); + vm.expectRevert(PoaManagerSatellite.UnauthorizedOrigin.selector); + satellite.handle(999, bytes32(uint256(uint160(hubAddr))), body); + } + + // ══════════════════════════════════════════════════════════ + // 37. handle admin call rejects wrong sender + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallRejectsWrongSender() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + bytes memory body = _adminCallPayload(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + + vm.prank(mailbox); + vm.expectRevert(PoaManagerSatellite.UnauthorizedSender.selector); + satellite.handle(hubDomain, bytes32(uint256(uint160(address(0xBAD)))), body); + } + + // ══════════════════════════════════════════════════════════ + // 38. handle admin call paused reverts + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallPausedReverts() public { + MockAdminTargetSat target = new MockAdminTargetSat(address(pm)); + satellite.setPaused(true); + + bytes memory body = _adminCallPayload(address(target), abi.encodeWithSignature("setValueOnlyPM(uint256)", 1)); + + vm.prank(mailbox); + vm.expectRevert(PoaManagerSatellite.IsPaused.selector); + satellite.handle(hubDomain, bytes32(uint256(uint160(hubAddr))), body); + } + + // ══════════════════════════════════════════════════════════ + // 39. handle admin call reverts when target reverts + // ══════════════════════════════════════════════════════════ + + function testHandleAdminCallRevertsWhenTargetReverts() public { + SatRevertingTarget revertTarget = new SatRevertingTarget(); + + bytes memory body = _adminCallPayload(address(revertTarget), abi.encodeWithSignature("doRevert()")); + + vm.prank(mailbox); + vm.expectRevert("always reverts"); + satellite.handle(hubDomain, bytes32(uint256(uint160(hubAddr))), body); + } } /// @dev Mock target that gates a function behind msg.sender == poaManager @@ -491,3 +590,10 @@ contract MockAdminTargetSat { value = _val; } } + +/// @dev Mock target that always reverts (for satellite tests) +contract SatRevertingTarget { + function doRevert() external pure { + revert("always reverts"); + } +} diff --git a/test/SwitchableBeacon.t.sol b/test/SwitchableBeacon.t.sol index 8c47735..64ab7b4 100644 --- a/test/SwitchableBeacon.t.sol +++ b/test/SwitchableBeacon.t.sol @@ -3,76 +3,72 @@ pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/SwitchableBeacon.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; -/** - * @title SwitchableBeaconTest - * @notice Comprehensive unit tests for the SwitchableBeacon contract - * @dev Tests mirror mode, static mode, mode switching, access control, and edge cases - */ contract SwitchableBeaconTest is Test { - // Test contracts SwitchableBeacon public switchableBeacon; UpgradeableBeacon public poaBeacon; - // Mock implementation contracts MockImplementationV1 public implV1; MockImplementationV2 public implV2; - // Test addresses address public owner = address(this); address public newOwner = address(0x1234); address public unauthorized = address(0x5678); - // Events - event OwnerTransferred(address indexed previousOwner, address indexed newOwner); - event OwnershipTransferStarted(address indexed pendingOwner); - event OwnershipTransferCancelled(address indexed cancelledOwner); + // Events (must match OZ + SwitchableBeacon) + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner); event ModeChanged(SwitchableBeacon.Mode mode); event MirrorSet(address indexed mirrorBeacon); event Pinned(address indexed implementation); function setUp() public { - // Deploy mock implementations implV1 = new MockImplementationV1(); implV2 = new MockImplementationV2(); - - // Deploy POA global beacon with V1 poaBeacon = new UpgradeableBeacon(address(implV1), owner); + switchableBeacon = new SwitchableBeacon(owner, address(poaBeacon), address(0), SwitchableBeacon.Mode.Mirror); + } + + // ============ Constructor State Tests ============ + + function testConstructorSetsInitialState() public { + assertEq(switchableBeacon.owner(), owner); + assertEq(switchableBeacon.pendingOwner(), address(0)); + assertEq(switchableBeacon.mirrorBeacon(), address(poaBeacon)); + assertEq(switchableBeacon.staticImplementation(), address(0)); + assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); + } - // Deploy SwitchableBeacon in Mirror mode initially - switchableBeacon = new SwitchableBeacon( - owner, - address(poaBeacon), - address(0), // No static impl needed for Mirror mode - SwitchableBeacon.Mode.Mirror - ); + function testConstructorStaticModeSetsAllFields() public { + SwitchableBeacon sb = + new SwitchableBeacon(newOwner, address(poaBeacon), address(implV1), SwitchableBeacon.Mode.Static); + + assertEq(sb.owner(), newOwner); + assertEq(sb.mirrorBeacon(), address(poaBeacon)); + assertEq(sb.staticImplementation(), address(implV1)); + assertEq(uint256(sb.mode()), uint256(SwitchableBeacon.Mode.Static)); } // ============ Mirror Mode Tests ============ function testMirrorModeTracksPoaBeacon() public { - // Verify initial state assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); assertEq(switchableBeacon.implementation(), address(implV1)); - // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); - // Verify SwitchableBeacon now returns V2 assertEq(switchableBeacon.implementation(), address(implV2)); } function testMirrorModeWithZeroImplementationReverts() public { - // Deploy a beacon that returns zero address MockBrokenBeacon brokenBeacon = new MockBrokenBeacon(); - // Create SwitchableBeacon pointing to broken beacon SwitchableBeacon beacon = new SwitchableBeacon(owner, address(brokenBeacon), address(0), SwitchableBeacon.Mode.Mirror); - // Should revert when trying to get implementation vm.expectRevert(SwitchableBeacon.ImplNotSet.selector); beacon.implementation(); } @@ -80,23 +76,18 @@ contract SwitchableBeaconTest is Test { // ============ Static Mode Tests ============ function testStaticModeIsolatesFromPoaUpdates() public { - // Deploy in Static mode with V1 SwitchableBeacon staticBeacon = new SwitchableBeacon(owner, address(poaBeacon), address(implV1), SwitchableBeacon.Mode.Static); - // Verify initial state assertEq(uint256(staticBeacon.mode()), uint256(SwitchableBeacon.Mode.Static)); assertEq(staticBeacon.implementation(), address(implV1)); - // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); - // Verify static beacon still returns V1 assertEq(staticBeacon.implementation(), address(implV1)); } function testStaticModeWithZeroImplementationReverts() public { - // Should revert when creating in Static mode with zero implementation vm.expectRevert(SwitchableBeacon.ImplNotSet.selector); new SwitchableBeacon(owner, address(poaBeacon), address(0), SwitchableBeacon.Mode.Static); } @@ -104,11 +95,9 @@ contract SwitchableBeaconTest is Test { // ============ Mode Switching Tests ============ function testPinToCurrent() public { - // Start in Mirror mode tracking V1 assertEq(switchableBeacon.implementation(), address(implV1)); assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); - // Pin to current implementation vm.expectEmit(true, false, false, true); emit Pinned(address(implV1)); vm.expectEmit(false, false, false, true); @@ -116,23 +105,18 @@ contract SwitchableBeaconTest is Test { switchableBeacon.pinToCurrent(); - // Verify mode changed to Static assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Static)); assertEq(switchableBeacon.implementation(), address(implV1)); assertEq(switchableBeacon.staticImplementation(), address(implV1)); - // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); - // Verify still pinned to V1 assertEq(switchableBeacon.implementation(), address(implV1)); } function testPinToSpecificImplementation() public { - // Start in Mirror mode assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); - // Pin to V2 directly vm.expectEmit(true, false, false, true); emit Pinned(address(implV2)); vm.expectEmit(false, false, false, true); @@ -140,23 +124,18 @@ contract SwitchableBeaconTest is Test { switchableBeacon.pin(address(implV2)); - // Verify pinned to V2 assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Static)); assertEq(switchableBeacon.implementation(), address(implV2)); } function testSetMirrorResumesFollowing() public { - // Start in Static mode with V1 SwitchableBeacon staticBeacon = new SwitchableBeacon(owner, address(poaBeacon), address(implV1), SwitchableBeacon.Mode.Static); - // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); - // Verify still on V1 assertEq(staticBeacon.implementation(), address(implV1)); - // Switch to Mirror mode vm.expectEmit(true, false, false, true); emit MirrorSet(address(poaBeacon)); vm.expectEmit(false, false, false, true); @@ -164,19 +143,15 @@ contract SwitchableBeaconTest is Test { staticBeacon.setMirror(address(poaBeacon)); - // Verify now following V2 assertEq(uint256(staticBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); assertEq(staticBeacon.implementation(), address(implV2)); } function testSetMirrorWithNewBeacon() public { - // Create a second POA beacon with V2 UpgradeableBeacon poaBeacon2 = new UpgradeableBeacon(address(implV2), owner); - // Switch to the new beacon switchableBeacon.setMirror(address(poaBeacon2)); - // Verify now following new beacon assertEq(switchableBeacon.mirrorBeacon(), address(poaBeacon2)); assertEq(switchableBeacon.implementation(), address(implV2)); } @@ -185,49 +160,47 @@ contract SwitchableBeaconTest is Test { function testOnlyOwnerCanPin() public { vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorized)); switchableBeacon.pin(address(implV2)); vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorized)); switchableBeacon.pinToCurrent(); } function testOnlyOwnerCanSetMirror() public { vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorized)); switchableBeacon.setMirror(address(poaBeacon)); } function testOnlyOwnerCanTransferOwnership() public { vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorized)); switchableBeacon.transferOwnership(newOwner); } function testOwnershipTransfer() public { // Initiate ownership transfer - vm.expectEmit(true, false, false, false); - emit OwnershipTransferStarted(newOwner); + vm.expectEmit(true, true, false, false); + emit OwnershipTransferStarted(owner, newOwner); switchableBeacon.transferOwnership(newOwner); - // Owner should still be the original owner assertEq(switchableBeacon.owner(), owner); assertEq(switchableBeacon.pendingOwner(), newOwner); // Pending owner accepts ownership vm.prank(newOwner); vm.expectEmit(true, true, false, false); - emit OwnerTransferred(owner, newOwner); + emit OwnershipTransferred(owner, newOwner); switchableBeacon.acceptOwnership(); - // Verify new owner assertEq(switchableBeacon.owner(), newOwner); assertEq(switchableBeacon.pendingOwner(), address(0)); // Old owner can't perform restricted operations - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, owner)); switchableBeacon.pin(address(implV2)); // New owner can perform operations @@ -239,91 +212,89 @@ contract SwitchableBeaconTest is Test { function testAcceptOwnershipRevertsIfNotPendingOwner() public { switchableBeacon.transferOwnership(newOwner); - // Unauthorized address cannot accept vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotPendingOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, unauthorized)); switchableBeacon.acceptOwnership(); } - function testCancelOwnershipTransfer() public { - // Initiate ownership transfer + function testTransferOwnershipOverwritesPendingOwner() public { + // Initiate transfer to newOwner switchableBeacon.transferOwnership(newOwner); assertEq(switchableBeacon.pendingOwner(), newOwner); - // Cancel the transfer - vm.expectEmit(true, false, false, false); - emit OwnershipTransferCancelled(newOwner); - switchableBeacon.cancelOwnershipTransfer(); + // Overwrite with different pending owner (replaces cancel functionality) + address anotherOwner = address(0xABCD); + switchableBeacon.transferOwnership(anotherOwner); + assertEq(switchableBeacon.pendingOwner(), anotherOwner); - // Verify pending owner is cleared - assertEq(switchableBeacon.pendingOwner(), address(0)); - assertEq(switchableBeacon.owner(), owner); - - // Cancelled pending owner cannot accept + // Original pending owner can no longer accept vm.prank(newOwner); - vm.expectRevert(SwitchableBeacon.NotPendingOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, newOwner)); switchableBeacon.acceptOwnership(); - } - function testCancelOwnershipTransferRevertsWhenNoPending() public { - // No pending transfer to cancel - vm.expectRevert(SwitchableBeacon.NoPendingTransfer.selector); - switchableBeacon.cancelOwnershipTransfer(); + // New pending owner can accept + vm.prank(anotherOwner); + switchableBeacon.acceptOwnership(); + assertEq(switchableBeacon.owner(), anotherOwner); } - function testCancelOwnershipTransferOnlyOwner() public { - switchableBeacon.transferOwnership(newOwner); + // ============ Renounce Ownership Tests ============ + + function testRenounceOwnershipReverts() public { + vm.expectRevert(SwitchableBeacon.CannotRenounce.selector); + switchableBeacon.renounceOwnership(); + } - // Non-owner cannot cancel + function testRenounceOwnershipRevertsFromAnyone() public { vm.prank(unauthorized); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); - switchableBeacon.cancelOwnershipTransfer(); + vm.expectRevert(SwitchableBeacon.CannotRenounce.selector); + switchableBeacon.renounceOwnership(); } - // ============ Zero Address Guards ============ + // ============ Constructor Validation Tests ============ function testConstructorRevertsOnZeroOwner() public { - vm.expectRevert(SwitchableBeacon.ZeroAddress.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); new SwitchableBeacon(address(0), address(poaBeacon), address(implV1), SwitchableBeacon.Mode.Static); } function testConstructorRevertsOnZeroMirrorBeacon() public { - vm.expectRevert(SwitchableBeacon.ZeroAddress.selector); + vm.expectRevert(SwitchableBeacon.NotContract.selector); new SwitchableBeacon(owner, address(0), address(implV1), SwitchableBeacon.Mode.Mirror); } - // ============ Contract Validation Tests ============ - function testConstructorRevertsOnNonContractMirrorBeacon() public { - address eoa = address(0x1234); // EOA address + address eoa = address(0x1234); vm.expectRevert(SwitchableBeacon.NotContract.selector); new SwitchableBeacon(owner, eoa, address(0), SwitchableBeacon.Mode.Mirror); } function testConstructorRevertsOnNonContractStaticImpl() public { - address eoa = address(0x1234); // EOA address + address eoa = address(0x1234); vm.expectRevert(SwitchableBeacon.NotContract.selector); new SwitchableBeacon(owner, address(poaBeacon), eoa, SwitchableBeacon.Mode.Static); } + // ============ Input Validation Tests ============ + function testPinRevertsOnZeroAddress() public { - vm.expectRevert(SwitchableBeacon.ZeroAddress.selector); + vm.expectRevert(SwitchableBeacon.NotContract.selector); switchableBeacon.pin(address(0)); } function testPinRevertsOnNonContract() public { - address eoa = address(0x5678); // EOA address + address eoa = address(0x5678); vm.expectRevert(SwitchableBeacon.NotContract.selector); switchableBeacon.pin(eoa); } function testSetMirrorRevertsOnZeroAddress() public { - vm.expectRevert(SwitchableBeacon.ZeroAddress.selector); + vm.expectRevert(SwitchableBeacon.NotContract.selector); switchableBeacon.setMirror(address(0)); } function testSetMirrorRevertsOnNonContract() public { - address eoa = address(0x9999); // EOA address + address eoa = address(0x9999); vm.expectRevert(SwitchableBeacon.NotContract.selector); switchableBeacon.setMirror(eoa); } @@ -335,26 +306,23 @@ contract SwitchableBeaconTest is Test { switchableBeacon.setMirror(address(brokenBeacon)); } - function testTransferOwnershipRevertsOnZeroAddress() public { - vm.expectRevert(SwitchableBeacon.ZeroAddress.selector); - switchableBeacon.transferOwnership(address(0)); - } - - // ============ Helper View Functions ============ - - function testIsMirrorMode() public { - // Initially in Mirror mode - assertTrue(switchableBeacon.isMirrorMode()); + function testTransferOwnershipToZeroClearsPending() public { + // First set a pending owner + switchableBeacon.transferOwnership(newOwner); + assertEq(switchableBeacon.pendingOwner(), newOwner); - // Pin to static - switchableBeacon.pin(address(implV1)); - assertFalse(switchableBeacon.isMirrorMode()); + // Transfer to zero effectively cancels the pending transfer + switchableBeacon.transferOwnership(address(0)); + assertEq(switchableBeacon.pendingOwner(), address(0)); - // Back to mirror - switchableBeacon.setMirror(address(poaBeacon)); - assertTrue(switchableBeacon.isMirrorMode()); + // Original pending owner can no longer accept + vm.prank(newOwner); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, newOwner)); + switchableBeacon.acceptOwnership(); } + // ============ View Functions ============ + function testTryGetImplementation() public { // Test in Mirror mode (bool success, address impl) = switchableBeacon.tryGetImplementation(); @@ -380,29 +348,22 @@ contract SwitchableBeaconTest is Test { // ============ Integration with BeaconProxy ============ function testBeaconProxyIntegration() public { - // Deploy a BeaconProxy pointing to our SwitchableBeacon bytes memory initData = abi.encodeWithSignature("initialize()"); BeaconProxy proxy = new BeaconProxy(address(switchableBeacon), initData); - // Call through proxy (should use V1) MockImplementationV1 proxyV1 = MockImplementationV1(address(proxy)); assertEq(proxyV1.version(), "V1"); - // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); - // Call through proxy (should now use V2 in Mirror mode) MockImplementationV2 proxyV2 = MockImplementationV2(address(proxy)); assertEq(proxyV2.version(), "V2"); assertEq(proxyV2.newFeature(), "New in V2"); - // Pin to V2 switchableBeacon.pinToCurrent(); - // Upgrade POA beacon to V1 again poaBeacon.upgradeTo(address(implV1)); - // Proxy should still use V2 (pinned) assertEq(proxyV2.version(), "V2"); } @@ -411,14 +372,12 @@ contract SwitchableBeaconTest is Test { function testFuzzPin(uint256 seed) public { vm.assume(seed > 0 && seed < 1000); - // Create a valid contract address to pin address impl; if (seed % 3 == 0) { impl = address(new MockImplementationV1()); } else if (seed % 3 == 1) { impl = address(new MockImplementationV2()); } else { - // Deploy another mock contract impl = address(new MockImplementationV1()); } @@ -429,22 +388,17 @@ contract SwitchableBeaconTest is Test { } function testFuzzSetMirror(uint256 seed) public { - // Instead of fuzzing addresses directly, create valid beacons vm.assume(seed > 0 && seed < 100); - // Create different mock implementations based on seed MockImplementationV1 newImpl; if (seed % 2 == 0) { newImpl = new MockImplementationV1(); } else { - // Deploy another instance newImpl = new MockImplementationV1(); } - // Deploy a new UpgradeableBeacon with the implementation UpgradeableBeacon newBeacon = new UpgradeableBeacon(address(newImpl), owner); - // Set the new beacon as mirror switchableBeacon.setMirror(address(newBeacon)); assertEq(switchableBeacon.mirrorBeacon(), address(newBeacon)); assertEq(uint256(switchableBeacon.mode()), uint256(SwitchableBeacon.Mode.Mirror)); @@ -456,7 +410,7 @@ contract SwitchableBeaconTest is Test { switchableBeacon.transferOwnership(newAddr); assertEq(switchableBeacon.pendingOwner(), newAddr); - assertEq(switchableBeacon.owner(), owner); // Owner unchanged until accepted + assertEq(switchableBeacon.owner(), owner); vm.prank(newAddr); switchableBeacon.acceptOwnership(); diff --git a/test/UpgradeEdgeCases.t.sol b/test/UpgradeEdgeCases.t.sol index 0f4de06..b8b4c71 100644 --- a/test/UpgradeEdgeCases.t.sol +++ b/test/UpgradeEdgeCases.t.sol @@ -5,6 +5,7 @@ import "forge-std/Test.sol"; import "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol"; import "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol"; import {SwitchableBeacon} from "../src/SwitchableBeacon.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; /// @title UpgradeEdgeCasesTest /// @notice Tests the full upgrade chain: PoaBeacon → SwitchableBeacon → BeaconProxy → delegatecall @@ -67,7 +68,7 @@ contract UpgradeEdgeCasesTest is Test { // Pin to current V1 switchable.pinToCurrent(); - assertFalse(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Static)); // Upgrade POA beacon to V2 poaBeacon.upgradeTo(address(implV2)); @@ -126,7 +127,7 @@ contract UpgradeEdgeCasesTest is Test { // 2. Pin → Static switchable.pinToCurrent(); - assertFalse(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Static)); // 3. Upgrade POA to V2 (proxy should NOT see it) poaBeacon.upgradeTo(address(implV2)); @@ -135,7 +136,7 @@ contract UpgradeEdgeCasesTest is Test { // 4. Back to Mirror switchable.setMirror(address(poaBeacon)); - assertTrue(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Mirror)); // 5. Now on V2, state preserved MockUpgradeableV2 proxyV2 = MockUpgradeableV2(address(proxy)); @@ -162,7 +163,7 @@ contract UpgradeEdgeCasesTest is Test { switchable.pinToCurrent(); // State unchanged - still in Mirror mode - assertTrue(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Mirror)); } // ══════════════════════════════════════════════════════════════════════ @@ -186,7 +187,7 @@ contract UpgradeEdgeCasesTest is Test { // Recovery: pin to known-good implementation switchable.pin(address(implV1)); assertEq(switchable.implementation(), address(implV1)); - assertFalse(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Static)); // Proxy using this beacon now works BeaconProxy bp = new BeaconProxy(address(switchable), ""); @@ -236,7 +237,7 @@ contract UpgradeEdgeCasesTest is Test { // Pin directly to V3 (never served by mirror, which has V1) switchable.pin(address(implV3)); assertEq(switchable.implementation(), address(implV3)); - assertFalse(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Static)); // Proxy now uses V3 MockUpgradeableV3 proxyV3 = MockUpgradeableV3(address(proxy)); @@ -305,13 +306,13 @@ contract UpgradeEdgeCasesTest is Test { assertEq(switchable.pendingOwner(), address(0)); // Factory can no longer manage - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, factory)); switchable.pin(address(implV2)); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, factory)); switchable.setMirror(address(poaBeacon)); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, factory)); switchable.pinToCurrent(); // Executor can manage diff --git a/test/UpgradeSafety.t.sol b/test/UpgradeSafety.t.sol index 39601fb..119d254 100644 --- a/test/UpgradeSafety.t.sol +++ b/test/UpgradeSafety.t.sol @@ -25,6 +25,7 @@ import {PasskeyAccountFactory} from "../src/PasskeyAccountFactory.sol"; import {PaymasterHub} from "../src/PaymasterHub.sol"; import {PoaManager} from "../src/PoaManager.sol"; import {SwitchableBeacon} from "../src/SwitchableBeacon.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; /// @title UpgradeSafetyTest /// @notice Comprehensive tests verifying upgrade safety invariants for all upgradeable contracts @@ -247,7 +248,7 @@ contract UpgradeSafetyTest is Test { // Switch to static (pin to current) switchable.pinToCurrent(); assertEq(switchable.implementation(), address(implV1)); - assertTrue(!switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Static)); // Upgrade POA beacon to V2 - static beacon should NOT follow DummyImplV2 implV2 = new DummyImplV2(); @@ -259,7 +260,7 @@ contract UpgradeSafetyTest is Test { // Switch back to mirror - should now follow V2 switchable.setMirror(address(poaBeacon)); assertEq(switchable.implementation(), address(implV2)); - assertTrue(switchable.isMirrorMode()); + assertEq(uint256(switchable.mode()), uint256(SwitchableBeacon.Mode.Mirror)); } function testSwitchableBeaconOnlyOwnerCanSwitchModes() public { @@ -271,17 +272,17 @@ contract UpgradeSafetyTest is Test { // Non-owner cannot pin vm.prank(UNAUTHORIZED); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, UNAUTHORIZED)); switchable.pin(address(implV1)); // Non-owner cannot set mirror vm.prank(UNAUTHORIZED); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, UNAUTHORIZED)); switchable.setMirror(address(poaBeacon)); // Non-owner cannot pin to current vm.prank(UNAUTHORIZED); - vm.expectRevert(SwitchableBeacon.NotOwner.selector); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, UNAUTHORIZED)); switchable.pinToCurrent(); }