diff --git a/.cspell.json b/.cspell.json index 39fdcb20..e8153a66 100644 --- a/.cspell.json +++ b/.cspell.json @@ -95,6 +95,7 @@ "backgrounded", "reconstructable", "Württemberg", - "delegatecall" + "delegatecall", + "sponsorable" ] } diff --git a/ops/deploy_swarm_contracts_l1.sh b/ops/deploy_swarm_contracts_l1.sh index 94840954..5b72656f 100755 --- a/ops/deploy_swarm_contracts_l1.sh +++ b/ops/deploy_swarm_contracts_l1.sh @@ -44,7 +44,8 @@ # - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas # - BOND_TOKEN: Address of the ERC20 bond token (NODL) # - BASE_BOND: Bond amount in wei (e.g., 100000000000000000000 for 100 NODL) -# - OWNER: (optional) Contract owner address, defaults to deployer +# - NODL_ADMIN: (optional) Owner address for all deployed contracts, defaults to deployer +# - COUNTRY_MULTIPLIER: (optional) Country multiplier for bond calculation (0 = use default) # - L1_RPC: RPC URL for L1 (Sepolia or Mainnet) # - ETHERSCAN_API_KEY: For contract verification # @@ -157,9 +158,18 @@ preflight_checks() { exit 1 fi + # Ensure DEPLOYER_PRIVATE_KEY has 0x prefix + if [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]]; then + export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" + fi + + # Derive deployer address for defaults + DEPLOYER_ADDRESS=$(cast wallet address "$DEPLOYER_PRIVATE_KEY") + # Set defaults export BOND_TOKEN="${BOND_TOKEN:-$NODL}" export BASE_BOND="${BASE_BOND:-100000000000000000000}" # 100 NODL default + export NODL_ADMIN="${NODL_ADMIN:-$DEPLOYER_ADDRESS}" log_success "Pre-flight checks passed" } @@ -214,7 +224,7 @@ deploy_contracts() { log_info "Would deploy with:" log_info " BOND_TOKEN: $BOND_TOKEN" log_info " BASE_BOND: $BASE_BOND" - log_info " OWNER: ${OWNER:-deployer}" + log_info " NODL_ADMIN: ${NODL_ADMIN:-deployer}" log_info " RPC: $L1_RPC" fi @@ -372,7 +382,7 @@ print_summary() { echo " Explorer: $EXPLORER_URL/address/$SWARM_REGISTRY_PROXY" echo "" echo "Configuration:" - echo " Owner: ${OWNER:-deployer}" + echo " Owner: ${NODL_ADMIN:-deployer}" echo " Bond Token: $BOND_TOKEN" echo " Base Bond: $BASE_BOND wei" echo "" diff --git a/ops/deploy_swarm_contracts_zksync.sh b/ops/deploy_swarm_contracts_zksync.sh index 360b018b..5293b599 100755 --- a/ops/deploy_swarm_contracts_zksync.sh +++ b/ops/deploy_swarm_contracts_zksync.sh @@ -47,8 +47,13 @@ # The script loads from .env-test (testnet) or .env-prod (mainnet): # - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas # - NODL: Address of the NODL token (used as bond token) +# - FLEET_OPERATOR: Address of the backend swarm operator (whitelisted user) # - BASE_BOND: Bond amount in wei (e.g., 100000000000000000000 for 100 NODL) -# - OWNER: (optional) Contract owner address, defaults to deployer +# - NODL_ADMIN: (optional) Owner address for all deployed contracts, defaults to deployer +# - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster, defaults to NODL_ADMIN +# - COUNTRY_MULTIPLIER: (optional) Country multiplier for bond calculation (0 = use default) +# - BOND_QUOTA: (optional) Max bond amount sponsorable per period in wei +# - BOND_PERIOD: (optional) Quota renewal period in seconds # # ============================================================================= @@ -71,11 +76,15 @@ case "$NETWORK" in ENV_FILE=".env-test" HARDHAT_NETWORK="zkSyncSepoliaTestnet" EXPLORER_URL="https://sepolia.explorer.zksync.io" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + FORGE_CHAIN="zksync-testnet" ;; mainnet) ENV_FILE=".env-prod" HARDHAT_NETWORK="zkSyncMainnet" EXPLORER_URL="https://explorer.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + FORGE_CHAIN="zksync" ;; *) echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." @@ -158,14 +167,26 @@ preflight_checks() { exit 1 fi + if [ -z "$FLEET_OPERATOR" ]; then + log_error "FLEET_OPERATOR not set in $ENV_FILE" + exit 1 + fi + # Ensure DEPLOYER_PRIVATE_KEY has 0x prefix (required by forge vm.envUint) if [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]]; then export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" fi + # Derive deployer address for defaults + DEPLOYER_ADDRESS=$(cast wallet address "$DEPLOYER_PRIVATE_KEY") + # Set defaults export BOND_TOKEN="${BOND_TOKEN:-$NODL}" - export BASE_BOND="${BASE_BOND:-100000000000000000000}" # 100 NODL default + export BASE_BOND="${BASE_BOND:-1000000000000000000000}" # 1000 NODL default + export NODL_ADMIN="${NODL_ADMIN:-$DEPLOYER_ADDRESS}" + export PAYMASTER_WITHDRAWER="${PAYMASTER_WITHDRAWER:-$NODL_ADMIN}" + export BOND_QUOTA="${BOND_QUOTA:-100000000000000000000000}" # 100000 NODL default + export BOND_PERIOD="${BOND_PERIOD:-86400}" # 1 day default log_success "Pre-flight checks passed" } @@ -309,17 +330,20 @@ deploy_contracts() { if [ "$BROADCAST" = "--broadcast" ]; then FORGE_ARGS+=("--broadcast" "--slow") - - # Add ZkSync-specific verification - if [ -n "$L2_VERIFIER_URL" ]; then - FORGE_ARGS+=("--verify" "--verifier" "zksync" "--verifier-url" "$L2_VERIFIER_URL") - fi + # NOTE: We do NOT add --verify here. forge script --verify sends absolute + # source paths which the ZkSync verifier rejects with "import with absolute + # or traversal path". Source code verification is handled separately in + # verify_source_code() using forge flatten + forge verify-contract. else log_warning "DRY RUN MODE - Add '--broadcast' to actually deploy" log_info "Would deploy with:" log_info " BOND_TOKEN: $BOND_TOKEN" log_info " BASE_BOND: $BASE_BOND" - log_info " OWNER: ${OWNER:-deployer}" + log_info " NODL_ADMIN: ${NODL_ADMIN:-deployer}" + log_info " PAYMASTER_WITHDRAWER: ${PAYMASTER_WITHDRAWER:-deployer}" + log_info " FLEET_OPERATOR: $FLEET_OPERATOR" + log_info " BOND_QUOTA: $BOND_QUOTA" + log_info " BOND_PERIOD: $BOND_PERIOD" log_info " RPC: $RPC_URL" return 0 fi @@ -338,9 +362,10 @@ deploy_contracts() { FLEET_IDENTITY_IMPL=$(grep -o 'FleetIdentity Implementation: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | grep -o '0x[0-9a-fA-F]*') SWARM_REGISTRY_PROXY=$(grep -o 'SwarmRegistry Proxy: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | grep -o '0x[0-9a-fA-F]*') SWARM_REGISTRY_IMPL=$(grep -o 'SwarmRegistry Implementation: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | grep -o '0x[0-9a-fA-F]*') + BOND_TREASURY_PAYMASTER=$(grep -o 'BondTreasuryPaymaster: 0x[0-9a-fA-F]*' "$DEPLOY_LOG" | grep -o '0x[0-9a-fA-F]*') # Validate we got addresses - if [ -z "$SERVICE_PROVIDER_PROXY" ] || [ -z "$FLEET_IDENTITY_PROXY" ] || [ -z "$SWARM_REGISTRY_PROXY" ]; then + if [ -z "$SERVICE_PROVIDER_PROXY" ] || [ -z "$FLEET_IDENTITY_PROXY" ] || [ -z "$SWARM_REGISTRY_PROXY" ] || [ -z "$BOND_TREASURY_PAYMASTER" ]; then log_error "Could not extract all addresses from output" log_info "Full output saved to: $DEPLOY_LOG" cat "$DEPLOY_LOG" @@ -401,9 +426,151 @@ verify_deployment() { SR_OWNER=$(cast call "$SWARM_REGISTRY_PROXY" "owner()(address)" --rpc-url "$RPC_URL") log_success "SwarmRegistry owner: $SR_OWNER" + # Test BondTreasuryPaymaster + log_info "Testing BondTreasuryPaymaster..." + BTP_TOKEN=$(cast call "$BOND_TREASURY_PAYMASTER" "bondToken()(address)" --rpc-url "$RPC_URL") + BTP_QUOTA=$(cast call "$BOND_TREASURY_PAYMASTER" "quota()(uint256)" --rpc-url "$RPC_URL") + log_success "BondTreasuryPaymaster bondToken: $BTP_TOKEN" + log_success "BondTreasuryPaymaster quota: $BTP_QUOTA" + log_success "All contracts verified successfully!" } +# ============================================================================= +# Step 4b: Verify Source Code on Block Explorer +# ============================================================================= +# +# Why a separate step: +# forge script --verify sends absolute file paths (e.g. /Users/me/project/src/...) +# which the ZkSync verifier rejects: "import with absolute or traversal path". +# +# Workaround: +# 1. Flatten each contract into a single .sol file (no imports) +# 2. Use forge verify-contract with the flattened file +# 3. Clean up temporary flat files +# +# Constructor args are extracted from the broadcast JSON using the ZkSync +# ContractDeployer ABI: create(bytes32 salt, bytes32 bytecodeHash, bytes ctorInput) +# +# ============================================================================= + +verify_source_code() { + if [ "$BROADCAST" != "--broadcast" ]; then + return 0 + fi + + log_info "Verifying source code on block explorer..." + + # Get RPC URL for chain detection + if [ "$NETWORK" = "mainnet" ]; then + RPC_URL="${L2_RPC:-https://mainnet.era.zksync.io}" + CHAIN_ID="324" + else + RPC_URL="${L2_RPC:-https://rpc.ankr.com/zksync_era_sepolia}" + CHAIN_ID="300" + fi + + BROADCAST_JSON="broadcast/DeploySwarmUpgradeableZkSync.s.sol/${CHAIN_ID}/run-latest.json" + if [ ! -f "$BROADCAST_JSON" ]; then + log_error "Broadcast file not found: $BROADCAST_JSON" + log_warning "Skipping source code verification" + return 1 + fi + + # Extract constructor args from broadcast JSON + # ZkSync ContractDeployer.create(): 0x9c4d535b + salt(32) + hash(32) + offset_to_ctor(32) + len(32) + ctor_data + log_info "Extracting constructor args from broadcast..." + CTOR_ARGS=$(python3 -c " +import json, sys +with open('$BROADCAST_JSON') as f: + data = json.load(f) +for tx in data['transactions']: + addr = (tx.get('additionalContracts') or [{}])[0].get('address', '') + inp = tx['transaction'].get('input', '') + payload = inp[10:] # skip 0x + 9c4d535b + offset = int(payload[128:192], 16) + ctor_start = offset * 2 + ctor_len = int(payload[ctor_start:ctor_start+64], 16) + ctor_args = payload[ctor_start+64:ctor_start+64+ctor_len*2] + print(f'{addr}:{ctor_args}') +") + + # Build lookup of address -> constructor args + declare -A CTOR_MAP + while IFS=: read -r addr args; do + CTOR_MAP["$addr"]="$args" + done <<< "$CTOR_ARGS" + + # Create temporary directory for flattened sources + FLAT_DIR=$(mktemp -d) + + # Flatten all unique contract sources + log_info "Flattening contract sources..." + forge flatten src/swarms/ServiceProviderUpgradeable.sol > "$FLAT_DIR/FlatSP.sol" + forge flatten src/swarms/FleetIdentityUpgradeable.sol > "$FLAT_DIR/FlatFI.sol" + forge flatten src/swarms/SwarmRegistryUniversalUpgradeable.sol > "$FLAT_DIR/FlatSR.sol" + forge flatten lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol > "$FLAT_DIR/FlatProxy.sol" + forge flatten src/paymasters/BondTreasuryPaymaster.sol > "$FLAT_DIR/FlatBTP.sol" + + # Copy flat files into src/ so forge can find them + cp "$FLAT_DIR/FlatSP.sol" src/FlatSP.sol + cp "$FLAT_DIR/FlatFI.sol" src/FlatFI.sol + cp "$FLAT_DIR/FlatSR.sol" src/FlatSR.sol + cp "$FLAT_DIR/FlatProxy.sol" src/FlatProxy.sol + cp "$FLAT_DIR/FlatBTP.sol" src/FlatBTP.sol + + VERIFY_FAILED=0 + + # Helper to verify a single contract + verify_one() { + local address="$1" + local source="$2" + local label="$3" + local ctor_key + ctor_key=$(echo "$address" | tr '[:upper:]' '[:lower:]') + local args="${CTOR_MAP[$ctor_key]}" + + local VARGS=( + --zksync + --chain "$FORGE_CHAIN" + --verifier zksync + --verifier-url "$VERIFIER_URL" + "$address" + "$source" + ) + if [ -n "$args" ]; then + VARGS+=(--constructor-args "$args") + fi + + log_info "Verifying $label at $address..." + if forge verify-contract "${VARGS[@]}" 2>&1; then + log_success "$label verified" + else + log_error "$label verification failed (can retry manually)" + VERIFY_FAILED=$((VERIFY_FAILED + 1)) + fi + } + + # Verify all 7 contracts + verify_one "$SERVICE_PROVIDER_IMPL" "src/FlatSP.sol:ServiceProviderUpgradeable" "ServiceProvider Implementation" + verify_one "$SERVICE_PROVIDER_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "ServiceProvider Proxy" + verify_one "$FLEET_IDENTITY_IMPL" "src/FlatFI.sol:FleetIdentityUpgradeable" "FleetIdentity Implementation" + verify_one "$FLEET_IDENTITY_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "FleetIdentity Proxy" + verify_one "$SWARM_REGISTRY_IMPL" "src/FlatSR.sol:SwarmRegistryUniversalUpgradeable" "SwarmRegistry Implementation" + verify_one "$SWARM_REGISTRY_PROXY" "src/FlatProxy.sol:ERC1967Proxy" "SwarmRegistry Proxy" + verify_one "$BOND_TREASURY_PAYMASTER" "src/FlatBTP.sol:BondTreasuryPaymaster" "BondTreasuryPaymaster" + + # Clean up flat files from src/ + rm -f src/FlatSP.sol src/FlatFI.sol src/FlatSR.sol src/FlatProxy.sol src/FlatBTP.sol + rm -rf "$FLAT_DIR" + + if [ "$VERIFY_FAILED" -gt 0 ]; then + log_warning "$VERIFY_FAILED contract(s) failed source verification (deployment itself succeeded)" + else + log_success "All 7 contracts source-code verified on block explorer!" + fi +} + # ============================================================================= # Step 5: Update Environment File # ============================================================================= @@ -438,6 +605,9 @@ update_env_file() { sed -i.bak '/^SWARM_REGISTRY_PROXY=/d' "$ENV_FILE" sed -i.bak '/^SWARM_REGISTRY_IMPL=/d' "$ENV_FILE" sed -i.bak '/^BASE_BOND=/d' "$ENV_FILE" + sed -i.bak '/^BOND_TREASURY_PAYMASTER=/d' "$ENV_FILE" + sed -i.bak '/^BOND_QUOTA=/d' "$ENV_FILE" + sed -i.bak '/^BOND_PERIOD=/d' "$ENV_FILE" # Clean up trailing blank lines sed -i.bak -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$ENV_FILE" rm -f "${ENV_FILE}.bak" @@ -453,7 +623,10 @@ FLEET_IDENTITY_PROXY=$FLEET_IDENTITY_PROXY FLEET_IDENTITY_IMPL=$FLEET_IDENTITY_IMPL SWARM_REGISTRY_PROXY=$SWARM_REGISTRY_PROXY SWARM_REGISTRY_IMPL=$SWARM_REGISTRY_IMPL +BOND_TREASURY_PAYMASTER=$BOND_TREASURY_PAYMASTER BASE_BOND=$BASE_BOND +BOND_QUOTA=$BOND_QUOTA +BOND_PERIOD=$BOND_PERIOD EOF log_success "Environment file updated" @@ -499,10 +672,18 @@ print_summary() { echo " Implementation: $SWARM_REGISTRY_IMPL" echo " Explorer: $EXPLORER_URL/address/$SWARM_REGISTRY_PROXY" echo "" + echo "BondTreasuryPaymaster:" + echo " Address: $BOND_TREASURY_PAYMASTER" + echo " Explorer: $EXPLORER_URL/address/$BOND_TREASURY_PAYMASTER" + echo "" echo "Configuration:" - echo " Owner: ${OWNER:-deployer}" - echo " Bond Token: $BOND_TOKEN" - echo " Base Bond: $BASE_BOND wei" + echo " Owner: ${NODL_ADMIN:-deployer}" + echo " Withdrawer: ${PAYMASTER_WITHDRAWER:-deployer}" + echo " Fleet Operator: $FLEET_OPERATOR" + echo " Bond Token: $BOND_TOKEN" + echo " Base Bond: $BASE_BOND wei" + echo " Bond Quota: $BOND_QUOTA wei" + echo " Bond Period: $BOND_PERIOD seconds" echo "" echo "==============================================" } @@ -525,6 +706,7 @@ main() { compile_contracts deploy_contracts verify_deployment + verify_source_code update_env_file print_summary diff --git a/script/DeploySwarmUpgradeable.s.sol b/script/DeploySwarmUpgradeable.s.sol index 3a9b080b..38735066 100644 --- a/script/DeploySwarmUpgradeable.s.sol +++ b/script/DeploySwarmUpgradeable.s.sol @@ -32,7 +32,8 @@ import {SwarmRegistryL1Upgradeable} from "../src/swarms/SwarmRegistryL1Upgradeab * - DEPLOYER_PRIVATE_KEY: Private key for deployment * - BOND_TOKEN: Address of the ERC20 bond token * - BASE_BOND: Base bond amount in wei - * - OWNER: Owner address for upgrade authorization (defaults to deployer) + * - COUNTRY_MULTIPLIER: (optional) Country multiplier for bond calculation (0 = use default) + * - NODL_ADMIN: (optional) Owner address for all deployed contracts (defaults to deployer) */ contract DeploySwarmUpgradeableL1 is Script { // Deployment artifacts @@ -49,7 +50,7 @@ contract DeploySwarmUpgradeableL1 is Script { address bondToken = vm.envAddress("BOND_TOKEN"); uint256 baseBond = vm.envUint("BASE_BOND"); uint256 countryMultiplier = vm.envOr("COUNTRY_MULTIPLIER", uint256(0)); // 0 means use the default - address owner = vm.envOr("OWNER", vm.addr(deployerPrivateKey)); + address owner = vm.envOr("NODL_ADMIN", vm.addr(deployerPrivateKey)); console.log("=== Deploying Upgradeable Swarm Contracts (L1) ==="); console.log("Bond Token:", bondToken); @@ -76,8 +77,9 @@ contract DeploySwarmUpgradeableL1 is Script { fleetIdentityImpl = address(new FleetIdentityUpgradeable()); console.log(" Implementation:", fleetIdentityImpl); - bytes memory fleetIdentityInitData = - abi.encodeWithSelector(FleetIdentityUpgradeable.initialize.selector, owner, bondToken, baseBond, countryMultiplier); + bytes memory fleetIdentityInitData = abi.encodeWithSelector( + FleetIdentityUpgradeable.initialize.selector, owner, bondToken, baseBond, countryMultiplier + ); fleetIdentityProxy = address(new ERC1967Proxy(fleetIdentityImpl, fleetIdentityInitData)); console.log(" Proxy:", fleetIdentityProxy); console.log(""); diff --git a/script/DeploySwarmUpgradeableZkSync.s.sol b/script/DeploySwarmUpgradeableZkSync.s.sol index cdb590fa..265e63c9 100644 --- a/script/DeploySwarmUpgradeableZkSync.s.sol +++ b/script/DeploySwarmUpgradeableZkSync.s.sol @@ -8,6 +8,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import {ServiceProviderUpgradeable} from "../src/swarms/ServiceProviderUpgradeable.sol"; import {FleetIdentityUpgradeable} from "../src/swarms/FleetIdentityUpgradeable.sol"; import {SwarmRegistryUniversalUpgradeable} from "../src/swarms/SwarmRegistryUniversalUpgradeable.sol"; +import {BondTreasuryPaymaster} from "../src/paymasters/BondTreasuryPaymaster.sol"; /** * @title DeploySwarmUpgradeableZkSync @@ -21,7 +22,12 @@ import {SwarmRegistryUniversalUpgradeable} from "../src/swarms/SwarmRegistryUniv * - DEPLOYER_PRIVATE_KEY: Private key for deployment * - BOND_TOKEN: Address of the ERC20 bond token * - BASE_BOND: Base bond amount in wei - * - OWNER: Owner address for upgrade authorization (defaults to deployer) + * - COUNTRY_MULTIPLIER: (optional) Country multiplier for bond calculation (0 = use default) + * - NODL_ADMIN: (optional) Owner address for all deployed contracts (defaults to deployer) + * - PAYMASTER_WITHDRAWER: (optional) Address allowed to withdraw tokens from paymaster (defaults to NODL_ADMIN) + * - BOND_QUOTA: (optional) Max bond amount sponsorable per period in wei (defaults to 100k NODL) + * - BOND_PERIOD: (optional) Quota renewal period in seconds (defaults to 1 day) + * - FLEET_OPERATOR: Address of the Nodle swarm operator (initial whitelisted user) */ contract DeploySwarmUpgradeableZkSync is Script { // Deployment artifacts @@ -31,6 +37,7 @@ contract DeploySwarmUpgradeableZkSync is Script { address public fleetIdentityImpl; address public swarmRegistryProxy; address public swarmRegistryImpl; + address public bondTreasuryPaymaster; function run() external { // Load environment variables @@ -38,12 +45,20 @@ contract DeploySwarmUpgradeableZkSync is Script { address bondToken = vm.envAddress("BOND_TOKEN"); uint256 baseBond = vm.envUint("BASE_BOND"); uint256 countryMultiplier = vm.envOr("COUNTRY_MULTIPLIER", uint256(0)); // 0 means use the default - address owner = vm.envOr("OWNER", vm.addr(deployerPrivateKey)); + address owner = vm.envOr("NODL_ADMIN", vm.addr(deployerPrivateKey)); + address withdrawer = vm.envOr("PAYMASTER_WITHDRAWER", owner); + uint256 bondQuota = vm.envOr("BOND_QUOTA", uint256(100_000 ether)); // 100k NODL default + uint256 bondPeriod = vm.envOr("BOND_PERIOD", uint256(1 days)); + address fleetOperator = vm.envAddress("FLEET_OPERATOR"); console.log("=== Deploying Upgradeable Swarm Contracts on ZkSync ==="); console.log("Bond Token:", bondToken); console.log("Base Bond:", baseBond); console.log("Owner:", owner); + console.log("Withdrawer:", withdrawer); + console.log("Bond Quota:", bondQuota); + console.log("Bond Period:", bondPeriod); + console.log("Fleet Operator:", fleetOperator); console.log(""); vm.startBroadcast(deployerPrivateKey); @@ -64,8 +79,9 @@ contract DeploySwarmUpgradeableZkSync is Script { fleetIdentityImpl = address(new FleetIdentityUpgradeable()); console.log(" Implementation:", fleetIdentityImpl); - bytes memory fleetIdentityInitData = - abi.encodeWithSelector(FleetIdentityUpgradeable.initialize.selector, owner, bondToken, baseBond, countryMultiplier); + bytes memory fleetIdentityInitData = abi.encodeWithSelector( + FleetIdentityUpgradeable.initialize.selector, owner, bondToken, baseBond, countryMultiplier + ); fleetIdentityProxy = address(new ERC1967Proxy(fleetIdentityImpl, fleetIdentityInitData)); console.log(" Proxy:", fleetIdentityProxy); console.log(""); @@ -81,6 +97,22 @@ contract DeploySwarmUpgradeableZkSync is Script { swarmRegistryProxy = address(new ERC1967Proxy(swarmRegistryImpl, swarmRegistryInitData)); console.log(" Proxy:", swarmRegistryProxy); + // 4. Deploy BondTreasuryPaymaster + console.log("4. Deploying BondTreasuryPaymaster..."); + address[] memory whitelistedContracts = new address[](3); + whitelistedContracts[0] = fleetIdentityProxy; + whitelistedContracts[1] = serviceProviderProxy; + whitelistedContracts[2] = swarmRegistryProxy; + address[] memory whitelistedUsers = new address[](1); + whitelistedUsers[0] = fleetOperator; + bondTreasuryPaymaster = address( + new BondTreasuryPaymaster( + owner, withdrawer, whitelistedContracts, whitelistedUsers, bondToken, bondQuota, bondPeriod + ) + ); + console.log(" Address:", bondTreasuryPaymaster); + console.log(""); + vm.stopBroadcast(); // Summary @@ -92,7 +124,11 @@ contract DeploySwarmUpgradeableZkSync is Script { console.log("FleetIdentity Implementation:", fleetIdentityImpl); console.log("SwarmRegistry Proxy:", swarmRegistryProxy); console.log("SwarmRegistry Implementation:", swarmRegistryImpl); + console.log("BondTreasuryPaymaster:", bondTreasuryPaymaster); console.log(""); console.log("Save these proxy addresses for future upgrades!"); + console.log( + "NOTE: Fund BondTreasuryPaymaster with bond tokens and whitelist users before sponsored claims work." + ); } } diff --git a/src/paymasters/BondTreasuryPaymaster.sol b/src/paymasters/BondTreasuryPaymaster.sol index 2011456a..98ac93cf 100644 --- a/src/paymasters/BondTreasuryPaymaster.sol +++ b/src/paymasters/BondTreasuryPaymaster.sol @@ -25,18 +25,27 @@ contract BondTreasuryPaymaster is WhitelistPaymaster, QuotaControl { address admin, address withdrawer, address[] memory initialWhitelistedContracts, + address[] memory initialWhitelistedUsers, address bondToken_, uint256 initialQuota, uint256 initialPeriod ) WhitelistPaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { bondToken = IERC20(bondToken_); uint256 n = initialWhitelistedContracts.length; - for (uint256 i = 0; i < n; i++) { + for (uint256 i = 0; i < n; ++i) { isWhitelistedContract[initialWhitelistedContracts[i]] = true; } if (n > 0) { emit WhitelistedContractsAdded(initialWhitelistedContracts); } + uint256 m = initialWhitelistedUsers.length; + for (uint256 j = 0; j < m; ++j) { + isWhitelistedUser[initialWhitelistedUsers[j]] = true; + } + if (m > 0) { + emit WhitelistedUsersAdded(initialWhitelistedUsers); + } + if (!isWhitelistedContract[address(this)]) { isWhitelistedContract[address(this)] = true; address[] memory selfDest = new address[](1); diff --git a/src/swarms/doc/spec/swarm-specification.md b/src/swarms/doc/spec/swarm-specification.md index 6dea88ca..374e9db5 100644 --- a/src/swarms/doc/spec/swarm-specification.md +++ b/src/swarms/doc/spec/swarm-specification.md @@ -83,13 +83,13 @@ graph TB ### 1.3 Core Components -| Contract | Role | Identity | Token | -| :------------------------- | :--------------------------------------- | :---------------------------------------------- | :---- | -| **FleetIdentity** | Fleet registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | -| **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | -| **SwarmRegistryL1** | Tag group registry (Ethereum L1) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | -| **SwarmRegistryUniversal** | Tag group registry (ZkSync Era, all EVM) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | -| **BondTreasuryPaymaster** | `WhitelistPaymaster` + `QuotaControl` + bond treasury | — | — | +| Contract | Role | Identity | Token | +| :------------------------- | :---------------------------------------------------- | :---------------------------------------------- | :---- | +| **FleetIdentity** | Fleet registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | +| **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | +| **SwarmRegistryL1** | Tag group registry (Ethereum L1) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | +| **SwarmRegistryUniversal** | Tag group registry (ZkSync Era, all EVM) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | +| **BondTreasuryPaymaster** | `WhitelistPaymaster` + `QuotaControl` + bond treasury | — | — | All contracts are **DAO-owned** (UUPS upgradeable) during initial operation, allowing parameter tuning and bug fixes. Once mature and stable, an upgrade can renounce ownership to make them fully **permissionless**. Access control is via NFT ownership; FleetIdentity requires an ERC-20 bond (e.g., NODL) as an anti-spam mechanism. @@ -656,10 +656,13 @@ sequenceDiagram // Deployment example (Nodle onboarding treasury) address[] memory initialContracts = new address[](1); initialContracts[0] = fleetIdentityProxy; +address[] memory initialUsers = new address[](1); +initialUsers[0] = nodleSwarmOperator; new BondTreasuryPaymaster( admin, withdrawer, initialContracts, + initialUsers, nodlTokenAddress, 100_000e18, // quota: 100,000 NODL per period 7 days // period length @@ -1441,15 +1444,15 @@ This enables a **Web2-style onboarding experience with full Web3 ownership**: a ### 11.2 Key Properties -| Property | Value | -| :------------------- | :--------------------------------------------------------------------------------------------------- | -| **Gas sponsorship** | Pays ZkSync gas for calls to whitelisted destinations by whitelisted users; also sponsors admin calls to itself | -| **Bond sponsorship** | Pays `BASE_BOND` NODL from its own balance via `claimUuidSponsored` | +| Property | Value | +| :------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Gas sponsorship** | Pays ZkSync gas for calls to whitelisted destinations by whitelisted users; also sponsors admin calls to itself | +| **Bond sponsorship** | Pays `BASE_BOND` NODL from its own balance via `claimUuidSponsored` | | **Allowed targets** | `isWhitelistedContract[to] && isWhitelistedUser[from]`. Constructor seeds `initialWhitelistedContracts` and always seeds `address(this)` for sponsored admin txs. Bond pullers use the same contract whitelist. | -| **Access control** | `admin`, `WHITELIST_ADMIN_ROLE`, `WITHDRAWER_ROLE` | -| **Quota control** | Inherits `QuotaControl` — configurable per-period NODL cap | -| **Paymaster base** | Inherits `WhitelistPaymaster` → `BasePaymaster` (shared whitelist paymaster implementation) | -| **Paymaster flow** | General flow only — approval-based flow not supported | +| **Access control** | `admin`, `WHITELIST_ADMIN_ROLE`, `WITHDRAWER_ROLE` | +| **Quota control** | Inherits `QuotaControl` — configurable per-period NODL cap | +| **Paymaster base** | Inherits `WhitelistPaymaster` → `BasePaymaster` (shared whitelist paymaster implementation) | +| **Paymaster flow** | General flow only — approval-based flow not supported | ### 11.3 Contract Interface @@ -1513,18 +1516,18 @@ This allows different sponsors with different policies (access lists, geographic ### 11.7 Events & Errors -| Event / Error | Type | Description | -| :----------------------------------- | :---- | :--------------------------------------------------------------------------------------------------------------------------------------- | -| `WhitelistedUsersAdded(users)` | Event | Emitted when users are added to the whitelist | -| `WhitelistedUsersRemoved(users)` | Event | Emitted when users are removed from the whitelist | -| `WhitelistedContractsAdded(contracts)` | Event | Emitted when contract destinations are added (including non-empty constructor `initialWhitelistedContracts`) | -| `WhitelistedContractsRemoved(contracts)` | Event | Emitted when contract destinations are removed | -| `TokensWithdrawn(token, to, amount)` | Event | Emitted on ERC-20 withdrawal | -| `UserIsNotWhitelisted()` | Error | User not in whitelist (bond or gas validation); defined on `WhitelistPaymaster` | -| `DestIsNotWhitelisted()` | Error | `to` not in `isWhitelistedContract`; defined on `WhitelistPaymaster` | -| `PaymasterBalanceTooLow()` | Error | Insufficient ETH to cover gas; defined on `WhitelistPaymaster` | -| `CallerNotWhitelistedContract()` | Error | `consumeSponsoredBond` caller not in `isWhitelistedContract` | -| `InsufficientBondBalance()` | Error | Paymaster NODL balance below requested bond amount | +| Event / Error | Type | Description | +| :--------------------------------------- | :---- | :----------------------------------------------------------------------------------------------------------- | +| `WhitelistedUsersAdded(users)` | Event | Emitted when users are added to the whitelist | +| `WhitelistedUsersRemoved(users)` | Event | Emitted when users are removed from the whitelist | +| `WhitelistedContractsAdded(contracts)` | Event | Emitted when contract destinations are added (including non-empty constructor `initialWhitelistedContracts`) | +| `WhitelistedContractsRemoved(contracts)` | Event | Emitted when contract destinations are removed | +| `TokensWithdrawn(token, to, amount)` | Event | Emitted on ERC-20 withdrawal | +| `UserIsNotWhitelisted()` | Error | User not in whitelist (bond or gas validation); defined on `WhitelistPaymaster` | +| `DestIsNotWhitelisted()` | Error | `to` not in `isWhitelistedContract`; defined on `WhitelistPaymaster` | +| `PaymasterBalanceTooLow()` | Error | Insufficient ETH to cover gas; defined on `WhitelistPaymaster` | +| `CallerNotWhitelistedContract()` | Error | `consumeSponsoredBond` caller not in `isWhitelistedContract` | +| `InsufficientBondBalance()` | Error | Paymaster NODL balance below requested bond amount | ### 11.8 Complete Sponsored Onboarding Flow diff --git a/test/paymasters/BondTreasuryPaymaster.t.sol b/test/paymasters/BondTreasuryPaymaster.t.sol index c6eda9ec..f5a00b57 100644 --- a/test/paymasters/BondTreasuryPaymaster.t.sol +++ b/test/paymasters/BondTreasuryPaymaster.t.sol @@ -44,10 +44,21 @@ contract MockBondTreasuryPaymaster is BondTreasuryPaymaster { address admin, address withdrawer, address[] memory initialWhitelistedContracts, + address[] memory initialWhitelistedUsers, address bondToken_, uint256 initialQuota, uint256 initialPeriod - ) BondTreasuryPaymaster(admin, withdrawer, initialWhitelistedContracts, bondToken_, initialQuota, initialPeriod) {} + ) + BondTreasuryPaymaster( + admin, + withdrawer, + initialWhitelistedContracts, + initialWhitelistedUsers, + bondToken_, + initialQuota, + initialPeriod + ) + {} function mock_validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) public view { _validateAndPayGeneralFlow(from, to, requiredETH); @@ -94,6 +105,16 @@ contract BondTreasuryPaymasterTest is Test { return c; } + function _emptyAddresses() internal pure returns (address[] memory) { + return new address[](0); + } + + function _singleAddress(address a) internal pure returns (address[] memory) { + address[] memory arr = new address[](1); + arr[0] = a; + return arr; + } + function setUp() public { bondToken = new MockERC20SCP(); @@ -104,17 +125,21 @@ contract BondTreasuryPaymasterTest is Test { ); fleet = FleetIdentityUpgradeable(address(proxy)); + address[] memory initialUsers = new address[](2); + initialUsers[0] = alice; + initialUsers[1] = admin; + paymaster = new MockBondTreasuryPaymaster( - admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, PERIOD + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + initialUsers, + address(bondToken), + QUOTA, + PERIOD ); bondToken.mint(address(paymaster), 10_000 ether); - - address[] memory initialUsers = new address[](2); - initialUsers[0] = alice; - initialUsers[1] = admin; - vm.prank(admin); - paymaster.addWhitelistedUsers(initialUsers); whitelistTargets = new address[](1); whitelistTargets[0] = alice; } @@ -141,6 +166,70 @@ contract BondTreasuryPaymasterTest is Test { assertTrue(paymaster.isWhitelistedContract(address(paymaster))); } + function test_initialWhitelistedUsersSetInConstructor() public view { + assertTrue(paymaster.isWhitelistedUser(alice)); + assertTrue(paymaster.isWhitelistedUser(admin)); + } + + function test_constructorWithEmptyWhitelistedUsers() public { + MockBondTreasuryPaymaster pm = new MockBondTreasuryPaymaster( + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + _emptyAddresses(), + address(bondToken), + QUOTA, + PERIOD + ); + assertFalse(pm.isWhitelistedUser(alice)); + assertFalse(pm.isWhitelistedUser(admin)); + } + + function test_constructorWithMultipleWhitelistedUsers() public { + address charlie = address(0xC); + address[] memory users = new address[](3); + users[0] = alice; + users[1] = bob; + users[2] = charlie; + + MockBondTreasuryPaymaster pm = new MockBondTreasuryPaymaster( + admin, withdrawer, _initialContractWhitelist(address(fleet)), users, address(bondToken), QUOTA, PERIOD + ); + assertTrue(pm.isWhitelistedUser(alice)); + assertTrue(pm.isWhitelistedUser(bob)); + assertTrue(pm.isWhitelistedUser(charlie)); + } + + function test_constructorEmitsWhitelistedUsersAdded() public { + address[] memory users = new address[](2); + users[0] = alice; + users[1] = bob; + + vm.expectEmit(); + emit WhitelistPaymaster.WhitelistedUsersAdded(users); + new MockBondTreasuryPaymaster( + admin, withdrawer, _initialContractWhitelist(address(fleet)), users, address(bondToken), QUOTA, PERIOD + ); + } + + function test_constructorEmptyUsersDoesNotEmitEvent() public { + vm.recordLogs(); + new MockBondTreasuryPaymaster( + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + _emptyAddresses(), + address(bondToken), + QUOTA, + PERIOD + ); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 usersAddedTopic = WhitelistPaymaster.WhitelistedUsersAdded.selector; + for (uint256 i = 0; i < logs.length; i++) { + assertTrue(logs[i].topics[0] != usersAddedTopic, "Should not emit WhitelistedUsersAdded for empty array"); + } + } + // ══════════════════════════════════════════════ // Whitelist Management // ══════════════════════════════════════════════ @@ -412,14 +501,15 @@ contract BondTreasuryPaymasterTest is Test { function test_quotaTracksBaseBondNotClaimCount() public { MockBondTreasuryPaymaster tightPaymaster = new MockBondTreasuryPaymaster( - admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), BASE_BOND / 2, PERIOD + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + _singleAddress(alice), + address(bondToken), + BASE_BOND / 2, + PERIOD ); - address[] memory targets = new address[](1); - targets[0] = alice; - vm.prank(admin); - tightPaymaster.addWhitelistedUsers(targets); - bondToken.mint(address(tightPaymaster), 10_000 ether); vm.prank(alice); @@ -490,13 +580,27 @@ contract BondTreasuryPaymasterTest is Test { function test_RevertIf_constructorZeroPeriod() public { vm.expectRevert(QuotaControl.ZeroPeriod.selector); - new MockBondTreasuryPaymaster(admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, 0); + new MockBondTreasuryPaymaster( + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + _emptyAddresses(), + address(bondToken), + QUOTA, + 0 + ); } function test_RevertIf_constructorTooLongPeriod() public { vm.expectRevert(QuotaControl.TooLongPeriod.selector); new MockBondTreasuryPaymaster( - admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, 31 days + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + _emptyAddresses(), + address(bondToken), + QUOTA, + 31 days ); }